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 " None: """ Inspect the HTML representation of a multidimensional triangle. @@ -201,6 +203,7 @@ def test_repr_html_multi(clrd: Triangle) -> None: assert " None: """ Extract the format string from a DataFrame when data are all nan. @@ -240,6 +243,187 @@ def test_get_format_str_medium() -> None: assert TriangleDisplay._get_format_str(data) == "{0:,.2f}" +def test_get_format_str_reads_from_options() -> None: + """ + Options for displaying small/medium/large triangles in Jupyter should + be passed TriangleDisplay. + + Returns + ------- + None + + """ + small = pd.DataFrame([[1.0, 2.0]]) + medium = pd.DataFrame([[100.0, 200.0]]) + large = pd.DataFrame([[10000.0, 20000.0]]) + try: + cl.options.set_option("display.html.auto_format_small", "{0:.1f}") + cl.options.set_option("display.html.auto_format_medium", "{0:.2f}") + cl.options.set_option("display.html.auto_format_large", "{0:.3f}") + assert TriangleDisplay._get_format_str(small) == "{0:.1f}" + assert TriangleDisplay._get_format_str(medium) == "{0:.2f}" + assert TriangleDisplay._get_format_str(large) == "{0:.3f}" + finally: + cl.options.reset_option("display.html.auto_format_small") + cl.options.reset_option("display.html.auto_format_medium") + cl.options.reset_option("display.html.auto_format_large") + + +def test_get_format_str_reads_thresholds_from_options() -> None: + """ + Changing the threshold at which a triangle is considered small/medium/large + should update the corresponding format string if the size classification of the + Triangle changes. + + Returns + ------- + None + + """ + data = pd.DataFrame([[500.0, 600.0]]) + assert TriangleDisplay._get_format_str(data) == "{0:,.2f}" + try: + # Triangle moves from medium to small. + cl.options.set_option("display.html.auto_format_small_threshold", 1000) + assert TriangleDisplay._get_format_str(data) == "{0:,.4f}" + finally: + cl.options.reset_option("display.html.auto_format_small_threshold") + assert TriangleDisplay._get_format_str(data) == "{0:,.2f}" + + try: + # Triangle moves from medium to large. + cl.options.set_option("display.html.auto_format_medium_threshold", 100) + assert TriangleDisplay._get_format_str(data) == "{:,.0f}" + finally: + cl.options.reset_option("display.html.auto_format_medium_threshold") + assert TriangleDisplay._get_format_str(data) == "{0:,.2f}" + + +def test_display_option_default_is_none() -> None: + """ + Without a user override, both display.value_format and + display.pattern_format should resolve to None, signaling that each + display pathway should fall back to its own default behavior. + + Returns + ------- + None + + """ + assert TriangleDisplay._display_option(is_pattern=False) is None + assert TriangleDisplay._display_option(is_pattern=True) is None + + +def test_normalize_format_wraps_format_string() -> None: + """ + A format-string value_format should be normalized into a callable + that correctly formats a provided value. + + Returns + ------- + None + + """ + formatter = TriangleDisplay._normalize_format("{:,.0f}") + assert formatter(1234.5) == "1,234" + + +def test_normalize_format_passes_through_callable() -> None: + """ + A callable value_format should be returned unchanged. + + Returns + ------- + None + + """ + + def swiss_format(x): + return f"{x:,.0f}".replace(",", "'") + + formatter = TriangleDisplay._normalize_format(swiss_format) + assert formatter is swiss_format + assert formatter(1234.5) == "1'234" + + +def test_value_format_override_affects_repr_and_html(raa: Triangle) -> None: + """ + Setting display.value_format should override both the console and + HTML/Jupyter default formatting for value triangles. + + Parameters + ---------- + raa: Triangle + The raa sample data set. + + Returns + ------- + None + + """ + try: + cl.options.set_option("display.value_format", "{:,.0f}") + assert "5,012" in repr(raa) + assert "5,012" in raa._repr_html_() + finally: + cl.options.reset_option("display.value_format") + # Back to the (unchanged) per-pathway defaults. + assert "5012.0" in repr(raa) + assert "5,012" not in repr(raa) + + +def test_value_format_override_affects_heatmap(raa: Triangle) -> None: + """ + Setting display.value_format with a callable should apply to + heatmap() as well. + + Parameters + ---------- + raa: Triangle + The raa sample data set. + + Returns + ------- + None + + """ + + def swiss_format(x): + return f"{x:,.0f}".replace(",", "'") + + try: + cl.options.set_option("display.value_format", swiss_format) + html_str = raa.heatmap().data + assert "5'012" in html_str + finally: + cl.options.reset_option("display.value_format") + + +def test_pattern_format_independent_of_value_format(raa: Triangle) -> None: + """ + display.pattern_format should only affect pattern triangles (e.g. + link_ratio), leaving value-triangle formatting untouched, and vice + versa. + + Parameters + ---------- + raa: Triangle + The raa sample data set. + + Returns + ------- + None + + """ + try: + cl.options.set_option("display.pattern_format", "{:.4f}".format) + assert "1.6498" in repr(raa.link_ratio) + # Value triangle repr is unaffected by the pattern-only override. + assert "5012.0" in repr(raa) + finally: + cl.options.reset_option("display.pattern_format") + + def test_repr_format_semi_annual(prism: Triangle) -> None: """ When origin has semiannual grain, "H1" and "H2" should appear in the index. @@ -337,11 +521,7 @@ def test_heatmap_no_ipython(raa: Triangle) -> None: """ import chainladder.core.display as display_mod - blocked = { - "IPython": None, - "IPython.core": None, - "IPython.core.display": None - } + blocked = {"IPython": None, "IPython.core": None, "IPython.core.display": None} with mock.patch.dict(sys.modules, blocked): importlib.reload(display_mod) with pytest.raises(ImportError, match=r"heatmap\(\) requires IPython\."): @@ -361,11 +541,7 @@ def test_display_import_fallback_when_ipython_missing() -> None: """ import chainladder.core.display as display_mod - blocked = { - "IPython": None, - "IPython.core": None, - "IPython.core.display": None - } + blocked = {"IPython": None, "IPython.core": None, "IPython.core.display": None} with mock.patch.dict(sys.modules, blocked): importlib.reload(display_mod) assert display_mod.HTML is None @@ -376,24 +552,16 @@ def test_display_import_fallback_when_ipython_missing() -> None: def test_heatmap_render(raa): """The heatmap method should render correctly given the sample.""" - try: - raa.heatmap() - - except: - assert False + raa.heatmap() def test_to_frame(raa): - try: - cl.Chainladder().fit(raa).cdf_.to_frame() - cl.Chainladder().fit(raa).cdf_.to_frame(origin_as_datetime=False) - cl.Chainladder().fit(raa).cdf_.to_frame(origin_as_datetime=True) - cl.Chainladder().fit(raa).ultimate_.to_frame() - cl.Chainladder().fit(raa).ultimate_.to_frame(origin_as_datetime=False) - cl.Chainladder().fit(raa).ultimate_.to_frame(origin_as_datetime=True) - - except: - assert False + cl.Chainladder().fit(raa).cdf_.to_frame() + cl.Chainladder().fit(raa).cdf_.to_frame(origin_as_datetime=False) + cl.Chainladder().fit(raa).cdf_.to_frame(origin_as_datetime=True) + cl.Chainladder().fit(raa).ultimate_.to_frame() + cl.Chainladder().fit(raa).ultimate_.to_frame(origin_as_datetime=False) + cl.Chainladder().fit(raa).ultimate_.to_frame(origin_as_datetime=True) def test_labels(xyz): diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index ed54981a..ae9966f8 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -18,7 +18,6 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from pytest import CaptureFixture from pytest import MonkeyPatch from chainladder import Triangle @@ -653,213 +652,6 @@ def test_minimum_2(raa): assert xp.all(xp.nan_to_num(result.values, nan=5000) <= 5000) -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) - - -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_set_backend_cupy_deprecated(clrd) -> None: """ Triangle.set_backend('cupy') should emit exactly one DeprecationWarning, @@ -1095,180 +887,3 @@ def test_dask_parallel_numpy_groupby_no_warning( assert dask_warnings == [] finally: cl._dask_parallel_state.warned = False - - -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/pyproject.toml b/pyproject.toml index 8094fbe7..7779468e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,7 +159,7 @@ select = ["E2", "E4", "E7", "E9", "F", "B018", "UP034", "N802"] "chainladder/workflow/voting.py" = ["E231", "E252", "E265"] ".github/scripts/pytest_parallel.py" = ["E241", "E702"] "docs/friedland/chapter_10.ipynb" = ["E731", "F841"] -"docs/friedland/chapter_7_part_2.ipynb" = ["N802"] +"docs/friedland/chapter_7_part_2.ipynb" = ["E241", "N802"] "docs/friedland/chapter_9.ipynb" = ["E731"] "docs/gallery/plot_advanced_triangle.ipynb" = ["E225"] "docs/gallery/plot_ave_analysis.ipynb" = ["E203", "E225"]