diff --git a/chainladder/__init__.py b/chainladder/__init__.py index 4f09953b..e287e753 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -14,11 +14,26 @@ # 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 +import re import numpy as np import pandas as pd +import warnings + from importlib.metadata import version +from typing import ( + Any, + Literal, + overload, + TYPE_CHECKING +) + +if TYPE_CHECKING: + from re import Match + from types import FrameType # Get the default datetime64 data type and precision, extracted from Pandas installation. @@ -26,6 +41,59 @@ __dt64_dtype__: str = pd.to_datetime(["2000-01-01"]).dtype.name __dt64_unit__: str = np.datetime_data(__dt64_dtype__)[0] +# Sentinel pattern used to mark a parameter as required and validate it. +_UNSET: Any = object() + +option_warning: str = ( + "The parameter 'option' is deprecated and will be removed in a future release. Use 'pat' instead." +) + + +@overload +def _resolve_pat(pat: str | None, option: str | None, required: Literal[True] = ...) -> str: ... +@overload +def _resolve_pat(pat: str | None, option: str | None, required: Literal[False]) -> str | None: ... +def _resolve_pat(pat: str | None, option: str | None, required: bool = True) -> str | None: + """ + Handles backward compatibility of 'options' parameter in options functions. Checks whether option or pat is + assigned a value and returns it. This value is meant to be assigned to the 'pat' parameter of the calling function. + + Parameters + ---------- + pat: str | None + The 'pat' parameter of the calling function. + option: str | None + The 'option' parameter of the calling function. + required: bool + Whether pat or option are required parameters in the calling function. Defaults to True. + + Returns + ------- + The value to be assigned to the 'pat' parameter of the calling function. + + """ + # Raise an error if the user accidentally assigns a value to both 'pat' and 'option'. + if pat is not None and option is not None: + raise TypeError("Cannot specify both 'pat' and 'option'.") + # Raise the deprecation warning if the user assigns a value to 'option'. + if option is not None: + warnings.warn(option_warning, FutureWarning, stacklevel=3) + pat: str = option + # Raise an error if neither 'option' nor 'pat' is assigned. + if pat is None and required: + # Determine the name of the calling function. + err: str = "Unable to determine calling function." + frame: FrameType | None = inspect.currentframe() + if frame is None: + raise AttributeError(err) + else: + f_back: FrameType | None = frame.f_back + if f_back is None: + raise AttributeError(err) + else: + caller: str = f_back.f_code.co_name + raise TypeError(f"{caller}() missing required argument: 'pat'.") + return pat class Options: """ @@ -57,13 +125,23 @@ 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 | None = None, + *, + option: str | None = None + ) -> str | bool | list: """ Get the option value for the specified option. + .. deprecated:: 0.9.3 + The ``option`` parameter is deprecated; use ``pat`` instead. + Parameters ---------- - option: str + pat: str | None + The option you wish to get the values for. + option: str | None The option you wish to get the values for. Returns @@ -71,56 +149,211 @@ def get_option(self, option: str) -> str | bool | list: The option value. """ - self._validate_option(option) - return getattr(self, option) + pat: str = _resolve_pat(pat=pat, option=option) + self._validate_option(pat) + return getattr(self, pat) def set_option( self, - option: str, - value: str | bool | list + pat: str | None = None, + value: str | bool | list = _UNSET, + *, + option: str | None = None ) -> None: """ Set the option value for the specified option. + .. deprecated:: 0.9.3 + The ``option`` parameter is deprecated; use ``pat`` instead. + Parameters ---------- - option: str + pat: str | None The option you wish to set the value for. value: str | bool | list The option value. + option: str | None + The option you wish to set the values for. Returns ------- None """ - self._validate_option(option) - setattr(self, option, value) + pat: str = _resolve_pat(pat=pat, option=option) + self._validate_option(pat) + if value is _UNSET: + raise TypeError("set_option() missing required argument: 'value'.") + setattr(self, pat, value) - def reset_option(self, option: str | None = None) -> None: + def reset_option( + self, + pat: str | None = None, + *, + option: str | None = None + ) -> None: """ Restores the default value for the specified option. Restores default values for - all options if option is None. + all options if pat is None. + + .. deprecated:: 0.9.3 + The ``option`` parameter is deprecated; use ``pat`` instead. + + Parameters + ---------- + pat: str | None + The option you wish to reset the value for. + option: str | None + The option you wish to reset the value for. Returns ------- None """ - - if option is not None: - self._validate_option(option) - setattr(self, option, copy.deepcopy(self._defaults[option])) + pat = _resolve_pat(pat=pat, option=option, required=False) + 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 Options 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: bool=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 + ------- + The description for the specified option(s) if _print_desc=False, otherwise, `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 + 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] + """ + # Match option names against pat as a regex. Empty pattern matches all. + try: + keys = [key for key in self._defaults if re.search(pat, key)] + except re.error: + raise ValueError(f"'{pat}' is not a valid regular expression.") + + 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"^{re.escape(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..5c6fd6d4 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,7 +10,11 @@ ) 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 + from pytest import MonkeyPatch @@ -291,4 +296,180 @@ 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_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('[') \ No newline at end of file