-
Notifications
You must be signed in to change notification settings - Fork 154
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
(feat): add methods for changing settings (#1270)
* (feat): add options features. * (feat): tests, doc strings * (feat): add settings to docs * (fix): add `describe_option` to exports, try to fix docs errors * (chore): add reset test * (fix): no multi-inheritance in py3.9 for NamedTuple * (refactor): use decorator * (chore): move options section * (chore): add release note * (refactor): class based implementation * (feat): add deprecation * (chore): clean up docstrings and variables * (chore): redo release note * (bug): fix `api.md` * (style): fix grammar * finish up typing * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * style * (feat): use attributes instead of items * (chore): no boolean without * * (feat): support multi-option functionality * (feat): add `__dir__` method * (fix): `default_value` typing * (feat): dynamic docstring as class method * (feat): tab completion in jupyter notebook for override * (feat): tab completion in jupyter notebook for override * (feat): docstring for `override` * (refactor): do docstring update in wrapped function * (chore): remove docstring types. * (fix): `KeyError` -> `AttributeError` * (refactor): `setattr` -> direct setting * (refactor): no more decorator for updating `override` * (refactor): relabel options docstring variable * (fix): docstring tab * (chore): add `override` to docs * (fix): clean up docstring methods * (chore): clean up unused methods/objects * (chore): add extra test * (fix): remove evironment variables * (chore): clarify `override` usage * Apply suggestions from code review Co-authored-by: Philipp A. <[email protected]> * (chore): add `dir` test * (fix): small docstring fix * (fix): validator api + tests with nice warnings * (chore): remove leading space from note * (chore): make docstring clearer * (fix): use `add_note` * (refactor): unnecessary `else` in guard clause * (fix): do not raise DeprecationWarning --------- Co-authored-by: Philipp A <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
- Loading branch information
1 parent
d07306f
commit c790113
Showing
5 changed files
with
442 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 # | ||
################################################################################## | ||
|
||
################################################################################## | ||
################################################################################## |
Oops, something went wrong.