diff --git a/anndata/__init__.py b/anndata/__init__.py index 6dd2e2192..07335213f 100644 --- a/anndata/__init__.py +++ b/anndata/__init__.py @@ -20,6 +20,7 @@ # Backport package for exception groups import exceptiongroup # noqa: F401 +from ._config import settings from ._core.anndata import AnnData from ._core.merge import concat from ._core.raw import Raw @@ -75,4 +76,5 @@ def read(*args, **kwargs): "ImplicitModificationWarning", "ExperimentalFeatureWarning", "experimental", + "settings", ] diff --git a/anndata/_config.py b/anndata/_config.py new file mode 100644 index 000000000..4a8fc0d42 --- /dev/null +++ b/anndata/_config.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import textwrap +import warnings +from collections.abc import Iterable +from contextlib import contextmanager +from inspect import Parameter, signature +from typing import TYPE_CHECKING, NamedTuple, TypeVar + +from anndata.compat.exceptiongroups import add_note + +if TYPE_CHECKING: + from collections.abc import Callable + +T = TypeVar("T") + + +class DeprecatedOption(NamedTuple): + option: str + message: str | None + removal_version: str | None + + +# TODO: inherit from Generic[T] as well after python 3.9 is no longer supported +class RegisteredOption(NamedTuple): + option: str + default_value: T + doc: str + validate: Callable[[T], bool] | None + type: object + + +_docstring = """ +This manager allows users to customize settings for the anndata package. +Settings here will generally be for advanced use-cases and should be used with caution. + +The following options are available: + +{options_description} + +For setting an option please use :func:`~anndata.settings.override` (local) or set the above attributes directly (global) i.e., `anndata.settings.my_setting = foo`. +""" + + +class SettingsManager: + _registered_options: dict[str, RegisteredOption] = {} + _deprecated_options: dict[str, DeprecatedOption] = {} + _config: dict[str, object] = {} + __doc_tmpl__: str = _docstring + + def describe( + self, + option: str | Iterable[str] | None = None, + *, + print_description: bool = True, + ) -> str: + """Print and/or return a (string) description of the option(s). + + Parameters + ---------- + option + Option(s) to be described, by default None (i.e., do all option) + print_description + Whether or not to print the description in addition to returning it., by default True + + Returns + ------- + The description. + """ + if option is None: + return self.describe( + self._registered_options.keys(), print_description=print_description + ) + if isinstance(option, Iterable) and not isinstance(option, str): + return "\n".join( + [self.describe(k, print_description=print_description) for k in option] + ) + registered_option = self._registered_options[option] + doc = registered_option.doc.rstrip("\n") + if option in self._deprecated_options: + opt = self._deprecated_options[option] + if opt.message is not None: + doc += " *" + opt.message + doc += f" {option} will be removed in {opt.removal_version}.*" + if print_description: + print(doc) + return doc + + def deprecate( + self, option: str, removal_version: str, message: str | None = None + ) -> None: + """Deprecate options with a message at a version. + + Parameters + ---------- + option + Which option should be deprecated. + removal_version + The version targeted for removal. + message + A custom message. + """ + self._deprecated_options[option] = DeprecatedOption( + option, message, removal_version + ) + + def register( + self, + option: str, + default_value: T, + description: str, + validate: Callable[[T], bool], + option_type: object | None = None, + ) -> None: + """Register an option so it can be set/described etc. by end-users + + Parameters + ---------- + option + Option to be set. + default_value + Default value with which to set the option. + description + Description to be used in the docstring. + validate + A function which returns True if the option's value is valid and otherwise should raise a `ValueError` or `TypeError`. + option + Optional override for the option type to be displayed. Otherwise `type(default_value)`. + """ + try: + validate(default_value) + except (ValueError, TypeError) as e: + add_note(e, f"for option {repr(option)}") + raise e + option_type_str = ( + type(default_value).__name__ if option_type is None else str(option_type) + ) + option_type = type(default_value) if option_type is None else option_type + doc = f"""\ + {option}: {option_type_str} + {description} Default value of {default_value}. + """ + doc = textwrap.dedent(doc) + self._registered_options[option] = RegisteredOption( + option, default_value, doc, validate, option_type + ) + self._config[option] = default_value + self._update_override_function_for_new_option(option) + + def _update_override_function_for_new_option( + self, + option: str, + ): + """This function updates the keyword arguments, docstring, and annotations of the `SettingsManager.override` function as the `SettingsManager.register` method is called. + + Parameters + ---------- + option + The option being registered for which the override function needs updating. + """ + option_type = self._registered_options[option].type + # Update annotations for type checking. + self.override.__annotations__[option] = option_type + # __signature__ needs to be updated for tab autocompletion in IPython. + # See https://github.com/ipython/ipython/issues/11624 for inspiration. + self.override.__func__.__signature__ = signature(self.override).replace( + parameters=[ + Parameter(name="self", kind=Parameter.POSITIONAL_ONLY), + *[ + Parameter( + name=k, + annotation=option_type, + kind=Parameter.KEYWORD_ONLY, + ) + for k in self._registered_options + ], + ] + ) + # Update docstring for `SettingsManager.override` as well. + insert_index = self.override.__doc__.find("\n Yields") + option_docstring = "\t" + "\t".join( + self.describe(option, print_description=False).splitlines(keepends=True) + ) + self.override.__func__.__doc__ = ( + self.override.__doc__[:insert_index] + + "\n" + + option_docstring + + self.override.__doc__[insert_index:] + ) + + def __setattr__(self, option: str, val: object) -> None: + """ + Set an option to a value. To see the allowed option to be set and their description, + use describe_option. + + Parameters + ---------- + option + Option to be set. + val + Value with which to set the option. + + Raises + ------ + AttributeError + If the option has not been registered, this function will raise an error. + """ + if hasattr(super(), option): + super().__setattr__(option, val) + elif option not in self._registered_options: + raise AttributeError( + f"{option} is not an available option for anndata.\ + Please open an issue if you believe this is a mistake." + ) + registered_option = self._registered_options[option] + registered_option.validate(val) + self._config[option] = val + + def __getattr__(self, option: str) -> object: + """ + Gets the option's value. + + Parameters + ---------- + option + Option to be got. + + Returns + ------- + Value of the option. + """ + if option in self._deprecated_options: + deprecated = self._deprecated_options[option] + warnings.warn( + DeprecationWarning( + f"{repr(option)} will be removed in {deprecated.removal_version}. " + + deprecated.message + ) + ) + if option in self._config: + return self._config[option] + raise AttributeError(f"{option} not found.") + + def __dir__(self) -> Iterable[str]: + return sorted((*dir(super()), *self._config.keys())) + + def reset(self, option: Iterable[str] | str) -> None: + """ + Resets option(s) to its (their) default value(s). + + Parameters + ---------- + option + The option(s) to be reset. + """ + if isinstance(option, Iterable) and not isinstance(option, str): + for opt in option: + self.reset(opt) + else: + self._config[option] = self._registered_options[option].default_value + + @contextmanager + def override(self, **overrides): + """ + Provides local override via keyword arguments as a context manager. + + Parameters + ---------- + + Yields + ------ + None + """ + restore = {a: getattr(self, a) for a in overrides} + try: + for attr, value in overrides.items(): + setattr(self, attr, value) + yield None + finally: + for attr, value in restore.items(): + setattr(self, attr, value) + + @property + def __doc__(self): + options_description = self.describe(print_description=False) + return self.__doc_tmpl__.format( + options_description=options_description, + ) + + +settings = SettingsManager() + +################################################################################## +# PLACE REGISTERED SETTINGS HERE SO THEY CAN BE PICKED UP FOR DOCSTRING CREATION # +################################################################################## + +################################################################################## +################################################################################## diff --git a/anndata/tests/test_config.py b/anndata/tests/test_config.py new file mode 100644 index 000000000..76961f1a1 --- /dev/null +++ b/anndata/tests/test_config.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import pytest + +from anndata._config import SettingsManager + +option = "test_var" +default_val = False +description = "My doc string!" + +option_2 = "test_var_2" +default_val_2 = False +description_2 = "My doc string 2!" + +option_3 = "test_var_3" +default_val_3 = [1, 2] +description_3 = "My doc string 3!" +type_3 = list[int] + + +def validate_bool(val) -> bool: + if not isinstance(val, bool): + raise TypeError(f"{val} not valid boolean") + return True + + +def validate_int_list(val) -> bool: + if not isinstance(val, list) or not [isinstance(type(e), int) for e in val]: + raise TypeError(f"{repr(val)} is not a valid int list") + return True + + +settings = SettingsManager() +settings.register(option, default_val, description, validate_bool) + +settings.register(option_2, default_val_2, description_2, validate_bool) + +settings.register( + option_3, + default_val_3, + description_3, + validate_int_list, + type_3, +) + + +def test_register_option_default(): + assert getattr(settings, option) == default_val + assert description in settings.describe(option) + + +def test_register_bad_option(): + with pytest.raises(TypeError, match="'foo' is not a valid int list"): + settings.register( + "test_var_4", + "foo", # should be a list of ints + description_3, + validate_int_list, + type_3, + ) + + +def test_set_option(): + setattr(settings, option, not default_val) + assert getattr(settings, option) == (not default_val) + settings.reset(option) + assert getattr(settings, option) == default_val + + +def test_dir(): + assert {option, option_2, option_3} <= set(dir(settings)) + assert dir(settings) == sorted(dir(settings)) + + +def test_reset_multiple(): + setattr(settings, option, not default_val) + setattr(settings, option_2, not default_val_2) + settings.reset([option, option_2]) + assert getattr(settings, option) == default_val + assert getattr(settings, option_2) == default_val_2 + + +def test_get_unregistered_option(): + with pytest.raises(AttributeError): + setattr(settings, option + "_different", default_val) + + +def test_override(): + with settings.override(**{option: not default_val}): + assert getattr(settings, option) == (not default_val) + assert getattr(settings, option) == default_val + + +def test_override_multiple(): + with settings.override(**{option: not default_val, option_2: not default_val_2}): + assert getattr(settings, option) == (not default_val) + assert getattr(settings, option_2) == (not default_val_2) + assert getattr(settings, option) == default_val + assert getattr(settings, option_2) == default_val_2 + + +def test_deprecation(): + warning = "This is a deprecation warning!" + version = "0.1.0" + settings.deprecate(option, version, warning) + described_option = settings.describe(option, print_description=False) + # first line is message, second two from deprecation + default_deprecation_message = f"{option} will be removed in {version}.*" + assert described_option.endswith(default_deprecation_message) + described_option = ( + described_option.rstrip().removesuffix(default_deprecation_message).rstrip() + ) + assert described_option.endswith(warning) + with pytest.warns( + DeprecationWarning, + match="'test_var' will be removed in 0.1.0. This is a deprecation warning!", + ): + assert getattr(settings, option) == default_val + + +def test_deprecation_no_message(): + version = "0.1.0" + settings.deprecate(option, version) + described_option = settings.describe(option, print_description=False) + # first line is message, second from deprecation version + assert described_option.endswith(f"{option} will be removed in {version}.*") + + +def test_option_typing(): + assert settings._registered_options[option_3].type == type_3 + assert str(type_3) in settings.describe(option_3, print_description=False) diff --git a/docs/api.md b/docs/api.md index bf9761be1..fb8f40f93 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,3 +142,13 @@ Utilities for customizing the IO process: ImplicitModificationWarning ``` + +## Settings + +```{eval-rst} +.. autosummary:: + :toctree: generated/ + + settings + settings.override +``` diff --git a/docs/release-notes/0.11.0.md b/docs/release-notes/0.11.0.md index 4e2e332db..e35e67b0d 100644 --- a/docs/release-notes/0.11.0.md +++ b/docs/release-notes/0.11.0.md @@ -2,6 +2,7 @@ ```{rubric} Features ``` +* Add `settings` object with methods for altering internally-used options, like checking for uniqueness on `obs`' index {pr}`1270` {user}`ilan-gold` ```{rubric} Bugfix ```