From f92a20d0edd20b96f6edaed2f078d3f87bf07845 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Thu, 28 May 2026 13:20:22 -0500 Subject: [PATCH 1/3] FEAT: Implement Options.describe_option. --- chainladder/__init__.py | 164 +++++++++++++++++++--- chainladder/utils/tests/test_utilities.py | 105 +++++++++++++- 2 files changed, 251 insertions(+), 18 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index 4f09953b..ccde6603 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -16,10 +16,14 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. import copy +import inspect +import re import numpy as np import pandas as pd from importlib.metadata import version +from typing import Match + # Get the default datetime64 data type and precision, extracted from Pandas installation. # Used for cross-version compatibility between Pandas 2 and Pandas 3. @@ -57,13 +61,13 @@ def __init__(self): # Store initial values as defaults. self._defaults = copy.deepcopy({k: v for k, v in vars(self).items() if not k.startswith('_')}) - def get_option(self, option: str) -> str | bool | list: + def get_option(self, pat: str) -> str | bool | list: """ Get the option value for the specified option. Parameters ---------- - option: str + pat: str The option you wish to get the values for. Returns @@ -71,12 +75,12 @@ def get_option(self, option: str) -> str | bool | list: The option value. """ - self._validate_option(option) - return getattr(self, option) + self._validate_option(pat) + return getattr(self, pat) def set_option( self, - option: str, + pat: str, value: str | bool | list ) -> None: """ @@ -84,7 +88,7 @@ def set_option( Parameters ---------- - option: str + pat: str The option you wish to set the value for. value: str | bool | list The option value. @@ -94,10 +98,10 @@ def set_option( None """ - self._validate_option(option) - setattr(self, option, value) + self._validate_option(pat) + setattr(self, pat, value) - def reset_option(self, option: str | None = None) -> None: + def reset_option(self, pat: str | None = None) -> None: """ Restores the default value for the specified option. Restores default values for all options if option is None. @@ -108,19 +112,145 @@ def reset_option(self, option: str | None = None) -> None: """ - if option is not None: - self._validate_option(option) - setattr(self, option, copy.deepcopy(self._defaults[option])) + if pat is not None: + self._validate_option(pat) + setattr(self, pat, copy.deepcopy(self._defaults[pat])) else: self.__init__() - def _validate_option(self, option: str) -> None: + def _validate_option(self, pat: str) -> None: + """ + Check whether string assigned to option is one of the configurable options in the Option class. + + Parameters + ---------- + pat: str + The option you want to check. + + Returns + ------- + None + + """ + + if pat not in self._defaults: + raise ValueError(f"Invalid option(s): {pat}. Must be one of {list(self._defaults)}.") + + def describe_option(self, pat: str = "", _print_desc=True) -> None | str: + """ + Print the description for one or more options. + + Call with no arguments to get a listing for all options. + + Parameters + ---------- + pat: str, default "" + The name of the option(s) you want described. Supplying an empty string will describe all options. + For multiple options, separate them with a pipe, |. + _print_desc: bool, default True + If True (default) the description(s) will be printed to stdout. + Otherwise, the description(s) will be returned as a string. + + Returns + ------- + None + + Examples + -------- + + Describe information on a single option by passing the option name to `pat`. + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + cl.options.describe_option("AUTO_SPARSE") + + .. testoutput:: + + AUTO_SPARSE : bool + Controls whether chainladder automatically converts a triangle's backing array to a sparse representation + when it would be memory-efficient to do so. + [default: True] [currently: True] + + You can use a regexp to look up information on multiple options. + + .. testcode:: + + cl.options.describe_option("AUTO_SPARSE|ARRAY_BACKEND") + + .. testoutput:: + + ARRAY_BACKEND : str + The default array backend for chainladder. + [default: numpy] [currently: numpy] + AUTO_SPARSE : bool + Controls whether chainladder automatically converts a triangle's backing array to a sparse representation + when it would be memory-efficient to do so. + [default: True] [currently: True] + + Setting `_print_desc=False` will return a string + + .. testcode:: + + res = cl.options.describe_option("AUTO_SPARSE", _print_desc=False) + print(res) + + .. testoutput:: + + "AUTO_SPARSE : bool\n Controls whether chainladder automatically converts a triangle's backing array + to a sparse representation\n when it would be memory-efficient to do so.\n + [default: True] [currently: True]" + """ + # Match option names against pat as a regex. Empty pattern matches all. + keys: list[str] = [key for key in self._defaults if re.search(pat, key)] + + if pat and not keys: + raise ValueError(f"No option matching '{pat}'. Must be one of {list(self._defaults)}.") + + # Extract class docstring and clean up indentation. + doc: str = inspect.cleandoc(self.__class__.__doc__) + + # Holds the output. + lines: list[str] = [] + for key in keys: + # Find a match for the specified option in the docstring. + match: Match[str] | None = re.search( + # 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"^{key}:\s*(\S+)\n((?:[ \t]+.+\n?)+)", + string=doc, + flags=re.MULTILINE # Needed to specify '^' as starting line anchor for each line. + ) + + # If there's a match, extract the attribute type and description. + if match: + type_hint: str = match.group(1) # Type annotation captured by (\S+) + description: str = inspect.cleandoc(match.group(2)) # Description block captured by ((?:[ \t]+.+\n?)+). + else: + type_hint: str = "" + description: str = "No description available." - if option not in self._defaults: - raise ValueError(f"Invalid option(s): {option}. Must be one of {list(self._defaults)}.") + # Indent the description relative to the attribute name. + indented: str = "\n ".join(description.splitlines()) + # Extract the default option values. + default: str | bool | list = self._defaults[key] + # Extract the current option values. + current: str | bool | list = getattr(self, key) + # Write the option followed by a type hint. + header: str = f"{key} : {type_hint}" if type_hint else key + # Indent the description relative to the header. + lines.append(f"{header}\n {indented}\n [default: {default}] [currently: {current}]") - def describe_option(self, option: str) -> str: - pass + output: str = "\n".join(lines) + # Print output by default, otherwise return the string. + if _print_desc: + print(output) + return None + return output options = Options() diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index 0aeffd27..27e4abcf 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pytest import chainladder as cl @@ -9,6 +10,10 @@ ) from chainladder.utils.utility_functions import date_delta_adjustment from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest import CaptureFixture @@ -291,4 +296,102 @@ def test_reset_option_invalid() -> None: None """ with pytest.raises(ValueError): - cl.options.reset_option('NOT_A_REAL_OPTION') \ No newline at end of file + cl.options.reset_option('NOT_A_REAL_OPTION') + + +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_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') \ No newline at end of file From 0444076d2a9a993b34190bb3e2e4792bb0226f2d Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Thu, 28 May 2026 14:03:01 -0500 Subject: [PATCH 2/3] TEST: Add missing test. --- chainladder/utils/tests/test_utilities.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index 27e4abcf..aa92443f 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from pytest import CaptureFixture - + from pytest import MonkeyPatch @@ -384,6 +384,25 @@ def test_describe_option_return_string() -> None: assert '[currently: numpy]' in result +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. From e61f903db5395ee98d31042d84c2b5c0edb2e069 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Thu, 28 May 2026 14:26:11 -0500 Subject: [PATCH 3/3] FIX: Apply Bugbot fix. --- chainladder/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index ccde6603..59eb7b4a 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -14,6 +14,7 @@ # 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/. +from __future__ import annotations import copy import inspect @@ -22,7 +23,10 @@ import pandas as pd from importlib.metadata import version -from typing import Match +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from re import Match # Get the default datetime64 data type and precision, extracted from Pandas installation.