diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 08a3369a..4e30f25c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,7 +10,8 @@ chainladder-python/ │ │ │ ├── _config/ # Package-wide configuration │ │ ├── options.py # Datetime constants, package options -│ │ └── deprecation.py # Deprecation utilities +│ │ ├── deprecation.py # Deprecation utilities +│ │ └── tests/ │ │ │ ├── core/ # Triangle data structure │ │ ├── triangle.py # Triangle (the public-facing class) diff --git a/chainladder/_config/options.py b/chainladder/_config/options.py index d125a1f8..eccbd4ec 100644 --- a/chainladder/_config/options.py +++ b/chainladder/_config/options.py @@ -55,6 +55,37 @@ class Options: when comparing or concatenating them. ULT_VAL: str The default ultimate valuation datetime, precision set to default of Pandas installation. + display.value_format: str, callable, or None + Controls how triangle values are formatted for display. Accepts a + Python format string (e.g. ``"{:,.0f}"``) applied to each value, or a + callable that takes a single value and returns its formatted string. + When ``None`` (the default), console output uses Pandas' own default + formatting, while Jupyter/HTML output and ``heatmap()`` continue to + auto-select a precision based on the magnitude of the values. Setting + this option overrides both. + display.pattern_format: str, callable, or None + Same as ``display.value_format``, but applies to pattern triangles + (e.g., link ratios, LDFs, CDFs) instead of value triangles. + display.html.auto_format_small: str + The format string used for Jupyter/HTML output and ``heatmap()`` + when ``display.value_format``/``display.pattern_format`` is ``None`` + and the mean absolute value being displayed is less than + ``display.html.auto_format_small_threshold``. Default: ``"{0:,.4f}"``. + display.html.auto_format_medium: str + Same as ``display.html.auto_format_small``, but for a mean absolute value + between ``display.html.auto_format_small_threshold`` and + ``display.html.auto_format_medium_threshold``. Default: ``"{0:,.2f}"``. + display.html.auto_format_large: str + Same as ``display.html.auto_format_small``, but for a mean absolute value + of ``display.html.auto_format_medium_threshold`` or more. Default: ``"{:,.0f}"``. + display.html.auto_format_small_threshold: int or float + The mean-absolute-value cutoff below which + ``display.html.auto_format_small`` is used instead of + ``display.html.auto_format_medium``. Default: ``10``. + display.html.auto_format_medium_threshold: int or float + The mean-absolute-value cutoff below which + ``display.html.auto_format_medium`` is used instead of + ``display.html.auto_format_large``. Default: ``1000``. """ @@ -65,6 +96,15 @@ def __init__(self): self.ULT_VAL = str( pd.Timestamp("2262-01-01") - pd.Timedelta(1, unit=__dt64_unit__) # noqa ) + # Dotted names are supported transparently: setattr/getattr/vars() + # all work with arbitrary string keys, not just valid identifiers. + setattr(self, "display.value_format", None) + setattr(self, "display.pattern_format", None) + setattr(self, "display.html.auto_format_small", "{0:,.4f}") + setattr(self, "display.html.auto_format_medium", "{0:,.2f}") + setattr(self, "display.html.auto_format_large", "{:,.0f}") + setattr(self, "display.html.auto_format_small_threshold", 10) + setattr(self, "display.html.auto_format_medium_threshold", 1000) # Store initial values as defaults. self._defaults = copy.deepcopy({ k: v for k, v in vars(self).items() if not k.startswith("_") @@ -295,7 +335,7 @@ def describe_option(self, pat: str = "", _print_desc: bool = True) -> None | str # Look for pattern matching structure of an attribute. e.g., the attribute name, followed by # the type name, then the attribute description indented on the next line. Search will be # split up into groups, specified by parentheses (). - pattern=rf"^{re.escape(key)}:\s*(\S+)\n((?:[ \t]+.+\n?)+)", + pattern=rf"^{re.escape(key)}:\s*([^\n]+)\n((?:[ \t]+.+\n?)+)", string=doc, flags=re.MULTILINE, # Needed to specify '^' as starting line anchor for each line. ) diff --git a/chainladder/_config/tests/__init__.py b/chainladder/_config/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/chainladder/_config/tests/test_options.py b/chainladder/_config/tests/test_options.py new file mode 100644 index 00000000..019fee60 --- /dev/null +++ b/chainladder/_config/tests/test_options.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +import pytest + +import chainladder as cl + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest import CaptureFixture + from pytest import MonkeyPatch + + +def test_reset_option() -> None: + """ + Change some of the options and then reset them. Values after reset should match the original values. + + Returns + ------- + None + + """ + + original_backend = cl.options.ARRAY_BACKEND + original_auto_sparse = cl.options.AUTO_SPARSE + original_array_priority = cl.options.ARRAY_PRIORITY + + try: + cl.options.set_option("ARRAY_BACKEND", "sparse") + cl.options.set_option("AUTO_SPARSE", False) + cl.options.set_option("ARRAY_PRIORITY", ["sparse", "dask", "numpy", "cupy"]) + + cl.options.reset_option() + + assert cl.options.ARRAY_BACKEND == original_backend + assert cl.options.AUTO_SPARSE == original_auto_sparse + assert cl.options.ARRAY_PRIORITY == original_array_priority + + finally: + # Manual reset in case of test failure. + cl.options.set_option("ARRAY_BACKEND", original_backend) + cl.options.set_option("AUTO_SPARSE", original_auto_sparse) + cl.options.set_option("ARRAY_PRIORITY", original_array_priority) + + +def test_options_defaults() -> None: + """ + When initialized, default options should be correct and accessible from the options variable. + + Returns + ------- + None + + """ + options = cl.Options() + assert options.ARRAY_BACKEND == "numpy" + assert options.AUTO_SPARSE + assert options.ARRAY_PRIORITY == ["dask", "sparse", "cupy", "numpy"] + assert isinstance(options.ULT_VAL, str) + assert options.get_option("display.value_format") is None + assert options.get_option("display.pattern_format") is None + assert options.get_option("display.html.auto_format_small") == "{0:,.4f}" + assert options.get_option("display.html.auto_format_medium") == "{0:,.2f}" + assert options.get_option("display.html.auto_format_large") == "{:,.0f}" + assert options.get_option("display.html.auto_format_small_threshold") == 10 + assert options.get_option("display.html.auto_format_medium_threshold") == 1000 + + +def test_display_format_options_dotted_names() -> None: + """ + display.value_format and display.pattern_format use dotted names rather + than the flat ALL_CAPS convention used by the other options, but should + otherwise support the full get_option/set_option/reset_option API, + since Options stores arbitrary string keys (not just valid Python + identifiers) in its instance ``__dict__``. + + Returns + ------- + None + + """ + try: + cl.options.set_option("display.value_format", "{:,.0f}") + assert cl.options.get_option("display.value_format") == "{:,.0f}" + + cl.options.set_option("display.pattern_format", "{:.4f}".format) + assert cl.options.get_option("display.pattern_format")(1.23456) == "1.2346" + finally: + cl.options.reset_option("display.value_format") + cl.options.reset_option("display.pattern_format") + + assert cl.options.get_option("display.value_format") is None + assert cl.options.get_option("display.pattern_format") is None + + +def test_display_format_option_invalid_name_raises() -> None: + """ + An unrecognized dotted option name should raise ValueError, same as any + other invalid option. + + Returns + ------- + None + + """ + with pytest.raises(ValueError): + cl.options.get_option("display.not_a_real_option") + + +def test_describe_option_display_value_format() -> None: + """ + describe_option should support the dotted display.value_format name and + its multi-word type hint (str, callable, or None). + + Returns + ------- + None + + """ + result = cl.options.describe_option("display.value_format", _print_desc=False) + assert isinstance(result, str) + assert "display.value_format : str, callable, or None" in result + assert "[default: None]" in result + assert "[currently: None]" in result + + +def test_get_option() -> None: + """ + get_option should return the appropriate attribute value. + + Returns + ------- + None + + """ + assert cl.options.get_option("ARRAY_BACKEND") == cl.options.ARRAY_BACKEND + assert cl.options.get_option("AUTO_SPARSE") == cl.options.AUTO_SPARSE + assert cl.options.get_option("ARRAY_PRIORITY") == cl.options.ARRAY_PRIORITY + assert cl.options.get_option("ULT_VAL") == cl.options.ULT_VAL + + +def test_set_option_consistency() -> None: + """ + When set_option changes an option value, get_option should return the new option value. + + Returns + ------- + None + + """ + try: + cl.options.set_option("ARRAY_BACKEND", "sparse") + assert cl.options.ARRAY_BACKEND == "sparse" + assert cl.options.get_option("ARRAY_BACKEND") == "sparse" + finally: + # Reset the options to default if the test fails. + cl.options.reset_option("ARRAY_BACKEND") + + +def test_reset_single_option() -> None: + """ + Set an option and check its value, then reset it and check its value. + + Returns + ------- + None + + """ + cl.options.set_option("ARRAY_BACKEND", "sparse") + assert cl.options.ARRAY_BACKEND == "sparse" + # Return backend to original state. + cl.options.reset_option("ARRAY_BACKEND") + assert cl.options.ARRAY_BACKEND == "numpy" + + +def test_reset_option_invalid() -> None: + """ + Supply in invalid option to cl.options.reset_option() and raise an error. + + Returns + ------- + None + """ + with pytest.raises(ValueError): + cl.options.reset_option("NOT_A_REAL_OPTION") + + +def test_set_option_cupy_backend_deprecated() -> None: + """ + Setting ARRAY_BACKEND to 'cupy' should emit a DeprecationWarning. See issue #843. + + Returns + ------- + None + """ + try: + with pytest.warns(DeprecationWarning, match="cupy"): + cl.options.set_option("ARRAY_BACKEND", "cupy") + finally: + cl.options.reset_option("ARRAY_BACKEND") + + +def test_set_option_dask_backend_deprecated() -> None: + """ + Setting ARRAY_BACKEND to 'dask' should emit a DeprecationWarning. See issue #842. + + Returns + ------- + None + """ + try: + with pytest.warns(DeprecationWarning, match="dask"): + cl.options.set_option("ARRAY_BACKEND", "dask") + finally: + cl.options.reset_option("ARRAY_BACKEND") + + +def test_set_option_cupy_priority_deprecated() -> None: + """ + Setting ARRAY_PRIORITY with 'cupy' ahead of a non-deprecated backend + ('numpy' or 'sparse') should emit a DeprecationWarning. See issue #843. + + Returns + ------- + None + """ + try: + with pytest.warns(DeprecationWarning, match="cupy"): + cl.options.set_option("ARRAY_PRIORITY", ["cupy", "numpy", "sparse", "dask"]) + finally: + cl.options.reset_option("ARRAY_PRIORITY") + + +def test_set_option_dask_priority_deprecated() -> None: + """ + Setting ARRAY_PRIORITY with 'dask' ahead of a non-deprecated backend + ('numpy' or 'sparse') should emit a DeprecationWarning. See issue #842. + + Returns + ------- + None + """ + try: + with pytest.warns(DeprecationWarning, match="dask"): + cl.options.set_option("ARRAY_PRIORITY", ["dask", "numpy", "sparse", "cupy"]) + finally: + cl.options.reset_option("ARRAY_PRIORITY") + + +def test_set_option_deprecated_priority_last_no_warning(recwarn) -> None: + """ + Setting ARRAY_PRIORITY with the deprecated backends ('cupy' and 'dask') + ranked below every non-deprecated backend should not warn, since neither + would ever be selected over a supported backend. See issues #842 and #843. + + Returns + ------- + None + """ + try: + cl.options.set_option("ARRAY_PRIORITY", ["numpy", "sparse", "dask", "cupy"]) + assert not [w for w in recwarn if issubclass(w.category, DeprecationWarning)] + finally: + cl.options.reset_option("ARRAY_PRIORITY") + + +def test_set_option_supported_backend_no_warning(recwarn) -> None: + """ + Setting a non-deprecated backend ('sparse'), and a priority list where no + deprecated backend precedes a supported one, should not emit a + DeprecationWarning. + + Returns + ------- + None + """ + try: + cl.options.set_option("ARRAY_BACKEND", "sparse") + cl.options.set_option("ARRAY_PRIORITY", ["sparse", "numpy"]) + assert not [w for w in recwarn if issubclass(w.category, DeprecationWarning)] + finally: + cl.options.reset_option("ARRAY_BACKEND") + cl.options.reset_option("ARRAY_PRIORITY") + + +def test_describe_option(capsys: CaptureFixture[str]) -> None: + """ + Supply an option to cl.options.describe_option(). Attribute name, type, default/current + settings should be captured in the output. + + Parameters + ---------- + capsys: CaptureFixture[str] + pytest built-in fixture to capture stdout + + Returns + ------- + None + + """ + cl.options.describe_option("ARRAY_BACKEND") + captured = capsys.readouterr() + assert "ARRAY_BACKEND : str" in captured.out + assert "[default: numpy]" in captured.out + assert "[currently: numpy]" in captured.out + + +def test_describe_option_multi(capsys) -> None: + """ + Supply two options to cl.options.describe_option(). Attribute names, types, default/current + settings should be captured in the output. + + Parameters + ---------- + capsys: CaptureFixture[str] + pytest built-in fixture to capture stdout + + Returns + ------- + None + + """ + cl.options.describe_option("ARRAY_BACKEND|AUTO_SPARSE") + captured = capsys.readouterr() + assert "ARRAY_BACKEND : str" in captured.out + assert "[default: numpy]" in captured.out + assert "[currently: numpy]" in captured.out + assert "AUTO_SPARSE : bool" in captured.out + assert "[default: True]" in captured.out + assert "[currently: True]" in captured.out + assert "ARRAY_PRIORITY" not in captured.out + + +def test_describe_option_all(capsys) -> None: + """ + Execute cl.options.describe_option() with default arguments. All attributes + should be captured. + + Parameters + ---------- + capsys: CaptureFixture[str] + pytest built-in fixture to capture stdout + + Returns + ------- + None + + """ + cl.options.describe_option() + captured = capsys.readouterr() + for key in cl.Options()._defaults: + assert key in captured.out + + +def test_describe_option_return_string() -> None: + """ + Execute cl.options.desribe_option() with _print_desc=False. Should return a string. Check + if attribute info is in the string. + + Returns + ------- + None + + """ + result = cl.options.describe_option("ARRAY_BACKEND", _print_desc=False) + assert isinstance(result, str) + assert "ARRAY_BACKEND : str" in result + assert "[default: numpy]" in result + assert "[currently: numpy]" in result + + +def test_deprecated_option_kwarg_warns() -> None: + """ + Passing option= to get_option or set_option should emit a FutureWarning. + """ + with pytest.warns(FutureWarning, match="'option'"): + cl.options.get_option(option="ARRAY_BACKEND") + + try: + with pytest.warns(FutureWarning, match="'option'"): + cl.options.set_option(option="ARRAY_BACKEND", value="numpy") + finally: + cl.options.reset_option("ARRAY_BACKEND") + + +def test_deprecated_option_kwarg_reset_option_warns() -> None: + """ + Passing option= to reset_option should emit a FutureWarning. + """ + try: + cl.options.set_option("ARRAY_BACKEND", "sparse") + with pytest.warns(FutureWarning, match="'option'"): + cl.options.reset_option(option="ARRAY_BACKEND") + assert cl.options.ARRAY_BACKEND == "numpy" + finally: + cl.options.reset_option("ARRAY_BACKEND") + + +def test_get_option_missing_pat_raises() -> None: + """ + Calling get_option() with neither pat nor option should raise TypeError. + """ + with pytest.raises(TypeError, match="missing required argument"): + cl.options.get_option() + + +def test_describe_option_no_docstring_match(monkeypatch: MonkeyPatch) -> None: + """ + When the class docstring has no entry for an option, describe_option should fall back + to 'No description available.' rather than raising an error. + + Parameters + ---------- + monkeypatch: MonkeyPatch + The pytest built-in monkeypatch fixture. + + Returns + ------- + None + """ + monkeypatch.setattr(cl.Options, "__doc__", "") + result = cl.options.describe_option("ARRAY_BACKEND", _print_desc=False) + assert "No description available." in result + + +def test_describe_option_invalid() -> None: + """ + Execute cl.options.desribe_option() with an invalid argument. Should raise a ValueError. + + Returns + ------- + None + + """ + with pytest.raises(ValueError): + cl.options.describe_option("NOT_A_REAL_OPTION") + + +def test_both_pat_and_option_raises() -> None: + """ + Passing both pat and option to get_option, set_option, or reset_option should raise TypeError. + """ + with pytest.raises(TypeError, match="Cannot specify both"): + cl.options.get_option(pat="ARRAY_BACKEND", option="ARRAY_BACKEND") + + +def test_set_option_missing_value_raises() -> None: + """ + Calling set_option with pat but no value should raise TypeError. + """ + with pytest.raises(TypeError, match="missing required argument"): + cl.options.set_option("ARRAY_BACKEND") + + +def test_describe_option_invalid_regex() -> None: + """ + Passing a malformed regular expression to describe_option should raise ValueError. + """ + with pytest.raises(ValueError, match="not a valid regular expression"): + cl.options.describe_option("[") diff --git a/chainladder/core/display.py b/chainladder/core/display.py index e2015b5f..5a11fc45 100644 --- a/chainladder/core/display.py +++ b/chainladder/core/display.py @@ -1,3 +1,7 @@ +""" +Handle Triangle printing behavior. +""" + # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. @@ -9,6 +13,8 @@ from typing import TYPE_CHECKING, Any +from chainladder import options + try: from IPython.core.display import HTML import IPython.display @@ -17,6 +23,7 @@ IPython = None if TYPE_CHECKING: + from collections.abc import Callable from pandas import DataFrame, IndexSlice, Series @@ -31,7 +38,11 @@ def __repr__(self) -> str | DataFrame: # DataFrame of the values. elif self._dimensionality == "single": data: DataFrame = self._repr_format() - return data.to_string() + value_format = self._display_option(is_pattern=self.is_pattern) + # Apply user-specified formatting, otherwise default to Pandas. + if value_format is None: + return data.to_string() + return data.to_string(float_format=self._normalize_format(value_format)) # For multidimensional triangles, return a summary. else: @@ -74,13 +85,18 @@ def _repr_html_(self) -> str: # Case single-dimensional triangle. elif self._dimensionality == "single": data = self._repr_format() - fmt_str = self._get_format_str(data=data) + value_format = self._display_option(is_pattern=self.is_pattern) + # Jupyter/HTML output auto-selects a precision based on the + # magnitude of the values unless the user has explicitly + # overridden it via display.value_format or display.pattern_format. + if value_format is None: + value_format = self._get_format_str(data=data) default = ( data .to_html( max_rows=pd.options.display.max_rows, max_cols=pd.options.display.max_columns, - float_format=fmt_str.format, + float_format=self._normalize_format(value_format), ) .replace("nan", "") .replace("NaN", "") @@ -105,12 +121,45 @@ def _get_format_str(data: DataFrame) -> str: """ if np.all(np.isnan(data)): return "" - elif np.nanmean(abs(data)) < 10: - return "{0:,.4f}" - elif np.nanmean(abs(data)) < 1000: - return "{0:,.2f}" + elif np.nanmean(abs(data)) < options.get_option( + "display.html.auto_format_small_threshold" + ): + return options.get_option("display.html.auto_format_small") + elif np.nanmean(abs(data)) < options.get_option( + "display.html.auto_format_medium_threshold" + ): + return options.get_option("display.html.auto_format_medium") else: - return "{:,.0f}" + return options.get_option("display.html.auto_format_large") + + @staticmethod + def _display_option(is_pattern: bool) -> str | Callable[[Any], str] | None: + """ + Look up the user-configured ``display.pattern_format`` or + ``display.value_format`` option. Returns ``None`` when the user + hasn't overridden the default value. + + Returns + ------- + str, callable, or None + """ + pat = "display.pattern_format" if is_pattern else "display.value_format" + return options.get_option(pat) + + @staticmethod + def _normalize_format( + value_format: str | Callable[[Any], str], + ) -> Callable[[Any], str]: + """ + Normalize a ``display.value_format``/``display.pattern_format`` value + (a format string or a callable) into a callable suitable for Pandas' + ``float_format`` argument. + + Returns + ------- + callable + """ + return value_format if callable(value_format) else value_format.format def _repr_format(self, origin_as_datetime: bool = False) -> DataFrame: """ @@ -176,7 +225,10 @@ def heatmap( """ if self._dimensionality == "single": data = self._repr_format() - fmt_str = self._get_format_str(data) + value_format = self._display_option(is_pattern=self.is_pattern) + # heatmap() follows Jupyter/HTML behavior. + if value_format is None: + value_format = self._get_format_str(data) axis = self._get_axis(axis) @@ -189,7 +241,7 @@ def heatmap( gmap = gmap.replace(np.nan, (shape_size + 1) / 2) default_output = ( data.style - .format(fmt_str) + .format(value_format) .background_gradient( cmap=cmap, low=low, diff --git a/chainladder/core/tests/test_display.py b/chainladder/core/tests/test_display.py index f54db90a..a227ec0b 100644 --- a/chainladder/core/tests/test_display.py +++ b/chainladder/core/tests/test_display.py @@ -39,6 +39,7 @@ def check_html(html: str) -> None: # Raise assertion error if one is detected. If so, print the error log as a list. assert len(parser.error_log) == 0, list(parser.error_log) + def test_check_html() -> None: """ Make sure check_html does its job on a malformed string. @@ -182,6 +183,7 @@ def test_repr_html_single(raa): assert "