diff --git a/src/tea_tasting/utils.py b/src/tea_tasting/utils.py index e09522e..58cc732 100644 --- a/src/tea_tasting/utils.py +++ b/src/tea_tasting/utils.py @@ -167,6 +167,141 @@ def format_num( return result +def get_and_format_num(data: dict[str, Any], key: str) -> str: + """Get and format dictionary value. + + Args: + data: Dictionary. + key: Key. + + Returns: + Formatted value. + """ + if key.endswith("_ci"): + ci_lower = get_and_format_num(data, key + "_lower") + ci_upper = get_and_format_num(data, key + "_upper") + return f"[{ci_lower}, {ci_upper}]" + + val = data.get(key) + if not isinstance(val, float | int | None): + return str(val) + + sig, pct = (2, True) if key.startswith("rel_") or key == "power" else (3, False) + return format_num(val, sig=sig, pct=pct) + + +class PrettyDictsMixin(abc.ABC): + """Pretty representation of a sequence of dictionaries.""" + default_keys: Sequence[str] + + @abc.abstractmethod + def to_dicts(self) -> Sequence[dict[str, Any]]: + """Convert the object to a sequence of dictionaries.""" + + def to_pandas(self) -> pd.DataFrame: + """Convert the object to a Pandas DataFrame.""" + return pd.DataFrame.from_records(self.to_dicts()) + + def to_pretty( + self, + keys: Sequence[str] | None = None, + formatter: Callable[[dict[str, Any], str], str] = get_and_format_num, + ) -> pd.DataFrame: + """Convert the object to a Pandas Dataframe with formatted values. + + Args: + keys: Keys to convert. If a key is not defined in the dictionary + it's assumed to be `None`. + formatter: Custom formatter function. It should accept a dictionary + of metric result attributes and an attribute name, and return + a formatted attribute value. + + Returns: + Pandas Dataframe with formatted values. + """ + if keys is None: + keys = self.default_keys + return pd.DataFrame.from_records( + {key: formatter(data, key) for key in keys} + for data in self.to_dicts() + ) + + def to_string( + self, + keys: Sequence[str] | None = None, + formatter: Callable[[dict[str, Any], str], str] = get_and_format_num, + ) -> str: + """Convert the object to a string. + + Args: + keys: Keys to convert. If a key is not defined in the dictionary + it's assumed to be `None`. + formatter: Custom formatter function. It should accept a dictionary + of metric result attributes and an attribute name, and return + a formatted attribute value. + + Returns: + A table with results rendered as string. + """ + if keys is None: + keys = self.default_keys + return self.to_pretty(keys, formatter).to_string(index=False) + + def to_html( + self, + keys: Sequence[str] | None = None, + formatter: Callable[[dict[str, Any], str], str] = get_and_format_num, + ) -> str: + """Convert the object to HTML. + + Args: + keys: Keys to convert. If a key is not defined in the dictionary + it's assumed to be `None`. + formatter: Custom formatter function. It should accept a dictionary + of metric result attributes and an attribute name, and return + a formatted attribute value. + + Returns: + A table with results rendered as HTML. + """ + if keys is None: + keys = self.default_keys + return self.to_pretty(keys, formatter).to_html(index=False) + + def __str__(self) -> str: + """Object string representation.""" + return self.to_string() + + def _repr_html_(self) -> str: + """Object HTML representation.""" + return self.to_html() + + +class ReprMixin: + """A mixin class that provides a method for generating a string representation. + + Representation string is generated based on parameters values saved in attributes. + """ + @classmethod + def _get_param_names(cls) -> Iterator[str]: + if cls.__init__ is object.__init__: + return + init_signature = inspect.signature(cls.__init__) + + for p in init_signature.parameters.values(): + if p.kind == p.VAR_POSITIONAL: + raise RuntimeError( + "There should not be positional parameters in the __init__.") + if p.name != "self" and p.kind != p.VAR_KEYWORD: + yield p.name + + def __repr__(self) -> str: + """Object representation.""" + params = {p: getattr(self, p) for p in self._get_param_names()} + params_repr = ", ".join(f"{k}={v!r}" for k, v in params.items()) + return f"{self.__class__.__name__}({params_repr})" + + def div( numer: float | int, denom: float | int, @@ -339,138 +474,3 @@ def numeric( return Int(value, fill_zero_div) except ValueError: return Float(value, fill_zero_div) - - -class ReprMixin: - """A mixin class that provides a method for generating a string representation. - - Representation string is generated based on parameters values saved in attributes. - """ - @classmethod - def _get_param_names(cls) -> Iterator[str]: - if cls.__init__ is object.__init__: - return - init_signature = inspect.signature(cls.__init__) - - for p in init_signature.parameters.values(): - if p.kind == p.VAR_POSITIONAL: - raise RuntimeError( - "There should not be positional parameters in the __init__.") - if p.name != "self" and p.kind != p.VAR_KEYWORD: - yield p.name - - def __repr__(self) -> str: - """Object representation.""" - params = {p: getattr(self, p) for p in self._get_param_names()} - params_repr = ", ".join(f"{k}={v!r}" for k, v in params.items()) - return f"{self.__class__.__name__}({params_repr})" - - -def get_and_format_num(data: dict[str, Any], key: str) -> str: - """Get and format dictionary value. - - Args: - data: Dictionary. - key: Key. - - Returns: - Formatted value. - """ - if key.endswith("_ci"): - ci_lower = get_and_format_num(data, key + "_lower") - ci_upper = get_and_format_num(data, key + "_upper") - return f"[{ci_lower}, {ci_upper}]" - - val = data.get(key) - if not isinstance(val, float | int | None): - return str(val) - - sig, pct = (2, True) if key.startswith("rel_") else (3, False) - return format_num(val, sig=sig, pct=pct) - - -class PrettyDictsMixin(abc.ABC): - """Pretty representation of a sequence of dictionaries.""" - default_keys: Sequence[str] - - @abc.abstractmethod - def to_dicts(self) -> Sequence[dict[str, Any]]: - """Convert the object to a sequence of dictionaries.""" - - def to_pandas(self) -> pd.DataFrame: - """Convert the object to a Pandas DataFrame.""" - return pd.DataFrame.from_records(self.to_dicts()) - - def to_pretty( - self, - keys: Sequence[str] | None = None, - formatter: Callable[[dict[str, Any], str], str] = get_and_format_num, - ) -> pd.DataFrame: - """Convert the object to a Pandas Dataframe with formatted values. - - Args: - keys: Keys to convert. If a key is not defined in the dictionary - it's assumed to be `None`. - formatter: Custom formatter function. It should accept a dictionary - of metric result attributes and an attribute name, and return - a formatted attribute value. - - Returns: - Pandas Dataframe with formatted values. - """ - if keys is None: - keys = self.default_keys - return pd.DataFrame.from_records( - {key: formatter(data, key) for key in keys} - for data in self.to_dicts() - ) - - def to_string( - self, - keys: Sequence[str] | None = None, - formatter: Callable[[dict[str, Any], str], str] = get_and_format_num, - ) -> str: - """Convert the object to a string. - - Args: - keys: Keys to convert. If a key is not defined in the dictionary - it's assumed to be `None`. - formatter: Custom formatter function. It should accept a dictionary - of metric result attributes and an attribute name, and return - a formatted attribute value. - - Returns: - A table with results rendered as string. - """ - if keys is None: - keys = self.default_keys - return self.to_pretty(keys, formatter).to_string(index=False) - - def to_html( - self, - keys: Sequence[str] | None = None, - formatter: Callable[[dict[str, Any], str], str] = get_and_format_num, - ) -> str: - """Convert the object to HTML. - - Args: - keys: Keys to convert. If a key is not defined in the dictionary - it's assumed to be `None`. - formatter: Custom formatter function. It should accept a dictionary - of metric result attributes and an attribute name, and return - a formatted attribute value. - - Returns: - A table with results rendered as HTML. - """ - if keys is None: - keys = self.default_keys - return self.to_pretty(keys, formatter).to_html(index=False) - - def __str__(self) -> str: - """Object string representation.""" - return self.to_string() - - def _repr_html_(self) -> str: - """Object HTML representation.""" - return self.to_html() diff --git a/tests/test_utils.py b/tests/test_utils.py index 8630992..1c4e892 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -142,6 +142,107 @@ def test_format_num(): assert tea_tasting.utils.format_num(0) == "0.00" +def test_get_and_format_num(): + data = { + "name": "metric", + "metric": 0.12345, + "rel_metric": 0.12345, + "metric_ci_lower": 0.12345, + "metric_ci_upper": 0.98765, + "rel_metric_ci_lower": 0.12345, + "rel_metric_ci_upper": 0.98765, + "power": 0.87654, + } + assert tea_tasting.utils.get_and_format_num(data, "name") == "metric" + assert tea_tasting.utils.get_and_format_num(data, "metric") == "0.123" + assert tea_tasting.utils.get_and_format_num(data, "rel_metric") == "12%" + assert tea_tasting.utils.get_and_format_num(data, "metric_ci") == "[0.123, 0.988]" + assert tea_tasting.utils.get_and_format_num(data, "rel_metric_ci") == "[12%, 99%]" + assert tea_tasting.utils.get_and_format_num(data, "power") == "88%" + + +@pytest.fixture +def pretty_dicts() -> tea_tasting.utils.PrettyDictsMixin: + class PrettyDicts(tea_tasting.utils.PrettyDictsMixin): + default_keys = ("a", "b") + def to_dicts(self) -> tuple[dict[str, Any], ...]: + return ( + {"a": 0.12345, "b": 0.23456}, + {"a": 0.34567, "b": 0.45678}, + {"a": 0.56789, "b": 0.67890}, + ) + return PrettyDicts() + +def test_pretty_dicts_mixin_to_pandas(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): + pd.testing.assert_frame_equal( + pretty_dicts.to_pandas(), + pd.DataFrame({ + "a": (0.12345, 0.34567, 0.56789), + "b": (0.23456, 0.45678, 0.67890), + }), + ) + +def test_pretty_dicts_mixin_to_pretty(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): + pd.testing.assert_frame_equal( + pretty_dicts.to_pretty(), + pd.DataFrame({ + "a": ("0.123", "0.346", "0.568"), + "b": ("0.235", "0.457", "0.679"), + }), + ) + +def test_pretty_dicts_mixin_to_string(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): + assert pretty_dicts.to_string() == pd.DataFrame({ + "a": ("0.123", "0.346", "0.568"), + "b": ("0.235", "0.457", "0.679"), + }).to_string(index=False) + +def test_pretty_dicts_mixin_to_html(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): + assert pretty_dicts.to_html() == pd.DataFrame({ + "a": ("0.123", "0.346", "0.568"), + "b": ("0.235", "0.457", "0.679"), + }).to_html(index=False) + +def test_pretty_dicts_mixin_str(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): + assert str(pretty_dicts) == pd.DataFrame({ + "a": ("0.123", "0.346", "0.568"), + "b": ("0.235", "0.457", "0.679"), + }).to_string(index=False) + +def test_pretty_dicts_mixin_repr_html(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): + assert pretty_dicts._repr_html_() == pd.DataFrame({ + "a": ("0.123", "0.346", "0.568"), + "b": ("0.235", "0.457", "0.679"), + }).to_html(index=False) + + +def test_repr_mixin_repr(): + class Repr(tea_tasting.utils.ReprMixin): + def __init__(self, a: int, *, b: bool, c: str) -> None: + self._a = -1 + self.a_ = -1 + self.a = a + self.b_ = -1 + self.b = b + self.c = c + r = Repr(a=1, b=False, c="c") + assert repr(r) == f"Repr(a=1, b=False, c={'c'!r})" + +def test_repr_mixin_repr_obj(): + class Obj(tea_tasting.utils.ReprMixin): + ... + obj = Obj() + assert repr(obj) == "Obj()" + +def test_repr_mixin_repr_pos(): + class Pos(tea_tasting.utils.ReprMixin): + def __init__(self, *args: int) -> None: + self.args = args + pos = Pos(1, 2, 3) + with pytest.raises(RuntimeError): + repr(pos) + + def test_div(): assert tea_tasting.utils.div(1, 2) == 0.5 assert tea_tasting.utils.div(1, 0, 3) == 3 @@ -221,102 +322,3 @@ def test_numeric(): assert isinstance(tea_tasting.utils.numeric("1"), tea_tasting.utils.Int) assert isinstance(tea_tasting.utils.numeric(1.0), tea_tasting.utils.Float) assert isinstance(tea_tasting.utils.numeric("inf"), tea_tasting.utils.Float) - - -def test_repr_mixin_repr(): - class Repr(tea_tasting.utils.ReprMixin): - def __init__(self, a: int, *, b: bool, c: str) -> None: - self._a = -1 - self.a_ = -1 - self.a = a - self.b_ = -1 - self.b = b - self.c = c - r = Repr(a=1, b=False, c="c") - assert repr(r) == f"Repr(a=1, b=False, c={'c'!r})" - -def test_repr_mixin_repr_obj(): - class Obj(tea_tasting.utils.ReprMixin): - ... - obj = Obj() - assert repr(obj) == "Obj()" - -def test_repr_mixin_repr_pos(): - class Pos(tea_tasting.utils.ReprMixin): - def __init__(self, *args: int) -> None: - self.args = args - pos = Pos(1, 2, 3) - with pytest.raises(RuntimeError): - repr(pos) - - -def test_get_and_format_num(): - data = { - "name": "metric", - "metric": 0.12345, - "rel_metric": 0.12345, - "metric_ci_lower": 0.12345, - "metric_ci_upper": 0.98765, - "rel_metric_ci_lower": 0.12345, - "rel_metric_ci_upper": 0.98765, - } - assert tea_tasting.utils.get_and_format_num(data, "name") == "metric" - assert tea_tasting.utils.get_and_format_num(data, "metric") == "0.123" - assert tea_tasting.utils.get_and_format_num(data, "rel_metric") == "12%" - assert tea_tasting.utils.get_and_format_num(data, "metric_ci") == "[0.123, 0.988]" - assert tea_tasting.utils.get_and_format_num(data, "rel_metric_ci") == "[12%, 99%]" - - -@pytest.fixture -def pretty_dicts() -> tea_tasting.utils.PrettyDictsMixin: - class PrettyDicts(tea_tasting.utils.PrettyDictsMixin): - default_keys = ("a", "b") - def to_dicts(self) -> tuple[dict[str, Any], ...]: - return ( - {"a": 0.12345, "b": 0.23456}, - {"a": 0.34567, "b": 0.45678}, - {"a": 0.56789, "b": 0.67890}, - ) - return PrettyDicts() - -def test_pretty_dicts_mixin_to_pandas(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): - pd.testing.assert_frame_equal( - pretty_dicts.to_pandas(), - pd.DataFrame({ - "a": (0.12345, 0.34567, 0.56789), - "b": (0.23456, 0.45678, 0.67890), - }), - ) - -def test_pretty_dicts_mixin_to_pretty(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): - pd.testing.assert_frame_equal( - pretty_dicts.to_pretty(), - pd.DataFrame({ - "a": ("0.123", "0.346", "0.568"), - "b": ("0.235", "0.457", "0.679"), - }), - ) - -def test_pretty_dicts_mixin_to_string(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): - assert pretty_dicts.to_string() == pd.DataFrame({ - "a": ("0.123", "0.346", "0.568"), - "b": ("0.235", "0.457", "0.679"), - }).to_string(index=False) - -def test_pretty_dicts_mixin_to_html(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): - assert pretty_dicts.to_html() == pd.DataFrame({ - "a": ("0.123", "0.346", "0.568"), - "b": ("0.235", "0.457", "0.679"), - }).to_html(index=False) - -def test_pretty_dicts_mixin_str(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): - assert str(pretty_dicts) == pd.DataFrame({ - "a": ("0.123", "0.346", "0.568"), - "b": ("0.235", "0.457", "0.679"), - }).to_string(index=False) - -def test_pretty_dicts_mixin_repr_html(pretty_dicts: tea_tasting.utils.PrettyDictsMixin): - assert pretty_dicts._repr_html_() == pd.DataFrame({ - "a": ("0.123", "0.346", "0.568"), - "b": ("0.235", "0.457", "0.679"), - }).to_html(index=False)