From 28af1cc9a4883a7cf47f2d6f456a2d33d1802455 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 21 Apr 2026 17:05:13 +0200 Subject: [PATCH 1/3] feat: pydantic settings --- .vscode/launch.json | 8 + .vscode/settings.json | 4 +- pyproject.toml | 2 + src/scanpy/_settings/__init__.py | 439 +++++++++---------------------- src/scanpy/_settings/presets.py | 9 +- src/scanpy/_singleton.py | 59 ----- src/scanpy/logging.py | 10 +- 7 files changed, 146 insertions(+), 385 deletions(-) delete mode 100644 src/scanpy/_singleton.py diff --git a/.vscode/launch.json b/.vscode/launch.json index d87ef7c54f..7fdc04463b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,6 +11,14 @@ "console": "internalConsole", "justMyCode": false, }, + { + "name": "Python: Debug", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "internalConsole", + "justMyCode": false, + }, { "name": "Python: Debug Test", "type": "debugpy", diff --git a/.vscode/settings.json b/.vscode/settings.json index 17d70d9420..9f5e9f8407 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,6 @@ "python.testing.pytestArgs": ["-vv", "--color=yes", "--internet-tests"], "python.testing.pytestEnabled": true, "python.terminal.activateEnvironment": true, - "python-envs.defaultEnvManager": "flying-sheep.hatch:hatch", - "python-envs.defaultPackageManager": "flying-sheep.hatch:hatch", + "python-envs.defaultEnvManager": "ms-python.python:venv", + "python-envs.defaultPackageManager": "ms-python.python:pip", } diff --git a/pyproject.toml b/pyproject.toml index b1e586eab7..e946dc6182 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ dependencies = [ "packaging>=25", "pandas>=2.3", "patsy", + "pydantic-settings", "pynndescent>=0.5.13", "scikit-learn>=1.6", "scipy>=1.13", @@ -206,6 +207,7 @@ lint.allowed-confusables = [ "×", "–", "‘", "’", "α" ] lint.external = [ "PLR0917" ] lint.flake8-bugbear.extend-immutable-calls = [ "scanpy._settings.Default" ] lint.flake8-type-checking.exempt-modules = [] +lint.flake8-type-checking.runtime-evaluated-base-classes = [ "pydantic_settings.BaseSettings" ] lint.flake8-type-checking.strict = true lint.isort.known-first-party = [ "scanpy", "testing.scanpy" ] lint.isort.required-imports = [ "from __future__ import annotations" ] diff --git a/src/scanpy/_settings/__init__.py b/src/scanpy/_settings/__init__.py index 63b85e7ebc..9f3a898677 100644 --- a/src/scanpy/_settings/__init__.py +++ b/src/scanpy/_settings/__init__.py @@ -1,23 +1,21 @@ from __future__ import annotations -import inspect import sys -from functools import wraps from pathlib import Path from time import time -from typing import TYPE_CHECKING, Literal, get_args +from typing import TYPE_CHECKING, Annotated, Literal, TextIO + +import pydantic_settings +from pydantic import AfterValidator from .. import logging from .._compat import deprecated -from .._singleton import SingletonMeta, documenting from ..logging import _RootLogger, _set_log_file, _set_log_level from .presets import Default, Preset from .verbosity import Verbosity if TYPE_CHECKING: - from collections.abc import Callable, Iterable - from types import UnionType - from typing import ClassVar, Concatenate, Self, TextIO + from collections.abc import Iterable from .verbosity import _VerbosityName @@ -34,57 +32,105 @@ AnnDataFileFormat = Literal["h5ad", "zarr"] -def _type_check(var: object, name: str, types: type | UnionType) -> None: - if isinstance(var, types): - return - if isinstance(types, type): - possible_types_str = types.__name__ - else: - type_names = [t.__name__ for t in get_args(types)] - possible_types_str = f"{', '.join(type_names[:-1])} or {type_names[-1]}" - msg = f"{name} must be of type {possible_types_str}" - raise TypeError(msg) - +def _default_logfile() -> TextIO: + return sys.stdout if _is_run_from_ipython() else sys.stderr -def _type_check_arg2[S, T, R, **P]( - types: type | UnionType, -) -> Callable[[Callable[Concatenate[S, T, P], R]], Callable[Concatenate[S, T, P], R]]: - def decorator( - func: Callable[Concatenate[S, T, P], R], - ) -> Callable[Concatenate[S, T, P], R]: - @wraps(func) - def wrapped(self: S, var: T, *args: P.args, **kwargs: P.kwargs) -> R: - __tracebackhide__ = True - _type_check(var, func.__name__, types) - return func(self, var, *args, **kwargs) - return wrapped +def _is_run_from_ipython() -> bool: + """Determine whether we're currently in IPython.""" + import builtins - return decorator + return getattr(builtins, "__IPYTHON__", False) # `type` is only here because of https://github.com/astral-sh/ruff/issues/20225 -class SettingsMeta(SingletonMeta, type): - _preset: Preset +class Settings(pydantic_settings.BaseSettings): + def model_post_init(self, context: object) -> None: + # logging + self._verbosity = Verbosity.warning + self._root_logger = _RootLogger(logging.WARNING) + self._logfile = _default_logfile() + self._logpath = None + _set_log_level(self) + _set_log_file(self) + + # figure + self._frameon = True + self._vector_friendly = False + self._low_resolution_warning = True + self._start = self._previous_time = time() + self._previous_memory_usage = -1 + + preset: Annotated[Preset, AfterValidator(Preset.check)] = Preset.ScanpyV1 + """Preset to use.""" + # logging + _verbosity: Verbosity _root_logger: _RootLogger _logfile: TextIO - _verbosity: Verbosity + _logpath: Path | None + # rest - _n_pcs: int - _plot_suffix: str - _file_format_data: AnnDataFileFormat - _file_format_figs: str - _autosave: bool - _autoshow: bool - _writedir: Path - _cachedir: Path - _datasetdir: Path - _figdir: Path - _cache_compression: Literal["lzf", "gzip"] | None - _max_memory: float - _n_jobs: int - _categories_to_ignore: list[str] + N_PCS: int = 50 + """Default number of principal components to use.""" + + plot_suffix: str = "" + """Global suffix that is appended to figure filenames.""" + + file_format_data: AnnDataFileFormat = "h5ad" + """File format for saving AnnData objects.""" + + file_format_figs: str = "pdf" + """File format for saving figures. + + For example `'png'`, `'pdf'` or `'svg'`. Many other formats work as well (see + :func:`matplotlib.pyplot.savefig`). + """ + + autosave: bool = False + """Automatically save figures in :attr:`~scanpy.settings.figdir` (default `False`). + + Do not show plots/figures interactively. + """ + + autoshow: bool = True + """Automatically show figures if `autosave == False` (default `True`). + + There is no need to call the matplotlib pl.show() in this case. + """ + + writedir: Path = Path("./write") + """Directory where the function scanpy.write writes to by default.""" + + cachedir: Path = Path("./cache") + """Directory for cache files (default `'./cache/'`).""" + + datasetdir: Annotated[Path, AfterValidator(Path.resolve)] = Path("./data") + """Directory for example :mod:`~scanpy.datasets` (default `'./data/'`).""" + + figdir: Path = Path("./figures") + r"""Directory for `autosave`\ ing figures (default `'./figures/'`).""" + + cache_compression: Literal["lzf", "gzip"] | None = "lzf" + """Compression for `sc.read(..., cache=True)` (default `'lzf'`).""" + + max_memory: float = 15.0 + """Maximum memory usage in Gigabyte. + + Is currently not well respected… + """ + + n_jobs: int = 1 + """Default number of jobs/ CPUs to use for parallel computing. + + Set to `-1` in order to use all available cores. + Not all algorithms support special behavior for numbers < `-1`, + so make sure to leave this setting as >= `-1`. + """ + + categories_to_ignore: list[str] = ["N/A", "dontknow", "no_gate", "?"] + """Categories that are omitted in plotting etc.""" + _frameon: bool """See set_figure_params.""" _vector_friendly: bool @@ -99,25 +145,14 @@ class SettingsMeta(SingletonMeta, type): """Stores the previous memory usage.""" @property - def preset(cls) -> Preset: - """Preset to use.""" - return cls._preset - - @preset.setter - def preset(cls, preset: Preset | str) -> None: - new_preset = Preset(preset) - new_preset.check() - cls._preset = new_preset - - @property - def verbosity(cls) -> Verbosity: + def verbosity(self) -> Verbosity: """Verbosity level (default :attr:`Verbosity.warning`).""" - return cls._verbosity + return self._verbosity @verbosity.setter - def verbosity(cls, verbosity: Verbosity | _VerbosityName | int) -> None: + def _set_verbosity(self, verbosity: Verbosity | _VerbosityName | int) -> None: try: - cls._verbosity = ( + self._verbosity = ( Verbosity[verbosity.lower()] if isinstance(verbosity, str) else Verbosity(verbosity) @@ -128,187 +163,25 @@ def verbosity(cls, verbosity: Verbosity | _VerbosityName | int) -> None: f"Accepted string values are: {Verbosity.__members__.keys()}" ) raise ValueError(msg) from None - _set_log_level(cls, cls._verbosity.level) - - @property - def N_PCS(cls) -> int: # noqa: N802 - """Default number of principal components to use.""" - return cls._n_pcs - - @N_PCS.setter - @_type_check_arg2(int) - def N_PCS(cls, n_pcs: int) -> None: # noqa: N802 - cls._n_pcs = n_pcs - - @property - def plot_suffix(cls) -> str: - """Global suffix that is appended to figure filenames.""" - return cls._plot_suffix - - @plot_suffix.setter - @_type_check_arg2(str) - def plot_suffix(cls, plot_suffix: str) -> None: - cls._plot_suffix = plot_suffix + _set_log_level(self) @property - def file_format_data(cls) -> AnnDataFileFormat: - """File format for saving AnnData objects.""" - return cls._file_format_data - - @file_format_data.setter - @_type_check_arg2(str) - def file_format_data(cls, file_format: AnnDataFileFormat) -> None: - if file_format not in (file_format_options := get_args(AnnDataFileFormat)): - msg = ( - f"Cannot set file_format_data to {file_format}. " - f"Must be one of {file_format_options}" - ) - raise ValueError(msg) - cls._file_format_data: AnnDataFileFormat = file_format - - @property - def file_format_figs(cls) -> str: - """File format for saving figures. - - For example `'png'`, `'pdf'` or `'svg'`. Many other formats work as well (see - :func:`matplotlib.pyplot.savefig`). - """ - return cls._file_format_figs - - @file_format_figs.setter - @_type_check_arg2(str) - def file_format_figs(cls, figure_format: str) -> None: - cls._file_format_figs = figure_format - - @property - def autosave(cls) -> bool: - """Automatically save figures in :attr:`~scanpy.settings.figdir` (default `False`). - - Do not show plots/figures interactively. - """ - return cls._autosave - - @autosave.setter - @_type_check_arg2(bool) - def autosave(cls, autosave: bool) -> None: - cls._autosave = autosave - - @property - def autoshow(cls) -> bool: - """Automatically show figures if `autosave == False` (default `True`). - - There is no need to call the matplotlib pl.show() in this case. - """ - return cls._autoshow - - @autoshow.setter - @_type_check_arg2(bool) - def autoshow(cls, autoshow: bool) -> None: - cls._autoshow = autoshow - - @property - def writedir(cls) -> Path: - """Directory where the function scanpy.write writes to by default.""" - return cls._writedir - - @writedir.setter - @_type_check_arg2(Path | str) - def writedir(cls, writedir: Path | str) -> None: - cls._writedir = Path(writedir) - - @property - def cachedir(cls) -> Path: - """Directory for cache files (default `'./cache/'`).""" - return cls._cachedir - - @cachedir.setter - @_type_check_arg2(Path | str) - def cachedir(cls, cachedir: Path | str) -> None: - cls._cachedir = Path(cachedir) - - @property - def datasetdir(cls) -> Path: - """Directory for example :mod:`~scanpy.datasets` (default `'./data/'`).""" - return cls._datasetdir - - @datasetdir.setter - @_type_check_arg2(Path | str) - def datasetdir(cls, datasetdir: Path | str) -> None: - cls._datasetdir = Path(datasetdir).resolve() - - @property - def figdir(cls) -> Path: - r"""Directory for `autosave`\ ing figures (default `'./figures/'`).""" - return cls._figdir - - @figdir.setter - @_type_check_arg2(Path | str) - def figdir(cls, figdir: Path | str) -> None: - cls._figdir = Path(figdir) - - @property - def cache_compression(cls) -> Literal["lzf", "gzip"] | None: - """Compression for `sc.read(..., cache=True)` (default `'lzf'`).""" - return cls._cache_compression - - @cache_compression.setter - def cache_compression( - cls, cache_compression: Literal["lzf", "gzip"] | None - ) -> None: - if cache_compression not in {"lzf", "gzip", None}: - msg = ( - f"`cache_compression` ({cache_compression}) " - "must be in {'lzf', 'gzip', None}" - ) - raise ValueError(msg) - cls._cache_compression = cache_compression - - @property - def max_memory(cls) -> int | float: - """Maximum memory usage in Gigabyte. - - Is currently not well respected… - """ - return cls._max_memory - - @max_memory.setter - @_type_check_arg2(int | float) - def max_memory(cls, max_memory: float) -> None: - cls._max_memory = max_memory - - @property - def n_jobs(cls) -> int: - """Default number of jobs/ CPUs to use for parallel computing. - - Set to `-1` in order to use all available cores. - Not all algorithms support special behavior for numbers < `-1`, - so make sure to leave this setting as >= `-1`. - """ - return cls._n_jobs - - @n_jobs.setter - @_type_check_arg2(int) - def n_jobs(cls, n_jobs: int) -> None: - cls._n_jobs = n_jobs - - @property - def logpath(cls) -> Path | None: + def logpath(self) -> Path | None: """The file path `logfile` was set to.""" - return cls._logpath + return self._logpath @logpath.setter - @_type_check_arg2(Path | str) - def logpath(cls, logpath: Path | str | None) -> None: + def logpath(self, logpath: Path | str | None) -> None: if logpath is None: - cls.logfile = None - cls._logpath = None + self.logfile = None + self._logpath = None return # set via “file object” branch of logfile.setter - cls.logfile = Path(logpath).open("a") # noqa: SIM115 - cls._logpath = Path(logpath) + self.logfile = Path(logpath).open("a") # noqa: SIM115 + self._logpath = Path(logpath) @property - def logfile(cls) -> TextIO: + def logfile(self) -> TextIO: """The open file to write logs to. Set it to a :class:`~pathlib.Path` or :class:`str` to open a new one. @@ -317,41 +190,29 @@ def logfile(cls) -> TextIO: For backwards compatibility, setting it to `''` behaves like setting it to `None`. """ - return cls._logfile + return self._logfile @logfile.setter - def logfile(cls, logfile: Path | str | TextIO | None) -> None: + def logfile(self, logfile: Path | str | TextIO | None) -> None: if not logfile: # "" or None - logfile = cls._default_logfile() + logfile = _default_logfile() if isinstance(logfile, Path | str): - cls.logpath = logfile + self.logpath = logfile return - cls._logfile = logfile - cls._logpath = None - _set_log_file(cls) - - @property - def categories_to_ignore(cls) -> list[str]: - """Categories that are omitted in plotting etc.""" - return cls._categories_to_ignore - - @categories_to_ignore.setter - def categories_to_ignore(cls, categories_to_ignore: Iterable[str]) -> None: - categories_to_ignore = list(categories_to_ignore) - for i, cat in enumerate(categories_to_ignore): - _type_check(cat, f"categories_to_ignore[{i}]", str) - cls._categories_to_ignore = categories_to_ignore + self._logfile = logfile + self._logpath = None + _set_log_file(self) # -------------------------------------------------------------------------------- # Functions # -------------------------------------------------------------------------------- @deprecated("Use `scanpy.set_figure_params` instead") - def set_figure_params(cls, *args, **kwargs) -> None: - cls._set_figure_params(*args, **kwargs) + def set_figure_params(self, *args, **kwargs) -> None: + self._set_figure_params(*args, **kwargs) def _set_figure_params( # noqa: PLR0913 - cls, + self, *, scanpy: bool = True, dpi: int = 80, @@ -402,7 +263,7 @@ def _set_figure_params( # noqa: PLR0913 for details. """ - if cls._is_run_from_ipython(): + if _is_run_from_ipython(): # No docs yet: https://github.com/ipython/matplotlib-inline/issues/12 from matplotlib_inline.backend_inline import set_matplotlib_formats @@ -413,8 +274,8 @@ def _set_figure_params( # noqa: PLR0913 from matplotlib import rcParams - cls._vector_friendly = vector_friendly - cls.file_format_figs = format + self._vector_friendly = vector_friendly + self.file_format_figs = format if dpi is not None: rcParams["figure.dpi"] = dpi if dpi_save is not None: @@ -430,62 +291,10 @@ def _set_figure_params( # noqa: PLR0913 set_rcParams_scanpy(fontsize=fontsize, color_map=color_map) if figsize is not None: rcParams["figure.figsize"] = figsize - cls._frameon = frameon - - @staticmethod - def _is_run_from_ipython() -> bool: - """Determine whether we're currently in IPython.""" - import builtins - - return getattr(builtins, "__IPYTHON__", False) + self._frameon = frameon - @classmethod - def _default_logfile(cls) -> TextIO: - return sys.stdout if cls._is_run_from_ipython() else sys.stderr + def __str__(self) -> str: + return "\n".join(f"{k} = {getattr(self, k)!r}" for k in type(self).model_fields) - def __str__(cls) -> str: - return "\n".join( - f"{k} = {v!r}" - for k, v in inspect.getmembers(cls) - if not k.startswith("_") and k != "getdoc" - ) - -class settings(metaclass=SettingsMeta): # noqa: N801 - """Settings for scanpy.""" - - def __new__(cls) -> type[Self]: - return cls - - _preset = Preset.ScanpyV1 - # logging - _root_logger: ClassVar = _RootLogger(logging.WARNING) - _logfile: ClassVar = SettingsMeta._default_logfile() - _logpath: ClassVar = None - _verbosity: ClassVar = Verbosity.warning - # rest - _n_pcs: ClassVar = 50 - _plot_suffix: ClassVar = "" - _file_format_data: ClassVar = "h5ad" - _file_format_figs: ClassVar = "pdf" - _autosave: ClassVar = False - _autoshow: ClassVar = True - _writedir: ClassVar = Path("./write") - _cachedir: ClassVar = Path("./cache") - _datasetdir: ClassVar = Path("./data") - _figdir: ClassVar = Path("./figures") - _cache_compression: ClassVar = "lzf" - _max_memory: ClassVar = 15 - _n_jobs: ClassVar = 1 - _categories_to_ignore: ClassVar = ["N/A", "dontknow", "no_gate", "?"] - _frameon: ClassVar = True - _vector_friendly: ClassVar = False - _low_resolution_warning: ClassVar = True - _start: ClassVar = time() - _previous_time: ClassVar = _start - _previous_memory_usage: ClassVar = -1 - - -if not documenting(): # finish initialization - _set_log_level(settings, settings.verbosity.level) - _set_log_file(settings) +settings = Settings() diff --git a/src/scanpy/_settings/presets.py b/src/scanpy/_settings/presets.py index bef0280b39..68f9644a17 100644 --- a/src/scanpy/_settings/presets.py +++ b/src/scanpy/_settings/presets.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Generator, Mapping + from typing import Self __all__ = [ @@ -237,14 +238,12 @@ def override(self, preset: Preset) -> Generator[Preset, None, None]: finally: settings.preset = self - def check(self) -> None: + def check(self) -> Self: """Check if requirements for preset are met.""" match self: - case self.ScanpyV1: - return case self.ScanpyV2Preview: if not (missing := _missing_scanpy2_deps()): - return + return self missing_str = ", ".join(f"‘{m.name}’" for m in missing) msg = ( f"Setting preset to {Preset.ScanpyV2Preview!r} requires optional " @@ -252,6 +251,8 @@ def check(self) -> None: "Install them with: pip install `scanpy[scanpy2]`" ) raise ImportError(msg) + case _: + return self def _missing_scanpy2_deps() -> list[Requirement]: diff --git a/src/scanpy/_singleton.py b/src/scanpy/_singleton.py deleted file mode 100644 index 06590e2b7d..0000000000 --- a/src/scanpy/_singleton.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -import os -from traceback import extract_stack -from types import FunctionType, MethodType - - -def documenting() -> bool: - """Return whether this is being called from Sphinx.""" - if not os.environ.get("SPHINX_RUNNING"): - return False - for frame in extract_stack(): - # Let any sphinx ext get the docstring - if frame.name in { - "eval_config_file", # Sphinx import - "generate_autosummary_docs", # Autosummary generator - # "parse_generated_content", # Autodoc parser - "get_object_members", # Class level of autodoc - "import_object", # Attr level of autodoc - }: - return True - return False - - -class SingletonMeta(type): - def __new__(mcls, cls_name: str, *args, **kwargs): - cls = super().__new__(mcls, cls_name, *args, **kwargs) - - # We do something differently when we are imported by autosummary. - if documenting(): - props = {} - for name in dir(cls): - if (attr := getattr(mcls, name, None)) is None: - continue - if isinstance(attr, FunctionType | MethodType): - # Circumvent https://github.com/tox-dev/sphinx-autodoc-typehints/pull/157 - setattr(cls, name, getattr(cls, name)) - if name not in cls.__dict__ and isinstance(attr, property): - # Allow autosummary to access the property, not the value - props[name] = getattr(mcls, name) - - def getattribute(_, name: str) -> object: - """Return property or value depending on whether we are in autosummary. - - If an singleton instance property/method is accessed by autodoc/autosummary, - return the property/method object, not the value/bound method. - """ - if documenting() and name in props: - return props[name] - return object.__getattribute__(cls, name) - - mcls.__getattribute__ = getattribute - - return cls - - def __dir__(cls) -> list[str]: - # Deduplicate preserving order - d = dict.fromkeys(super().__dir__()) | dict.fromkeys(dir(type(cls))) - return [k for k in d if k != "mro"] diff --git a/src/scanpy/logging.py b/src/scanpy/logging.py index 6086b19b82..c190710138 100644 --- a/src/scanpy/logging.py +++ b/src/scanpy/logging.py @@ -19,7 +19,7 @@ from session_info2 import SessionInfo - from ._settings import SettingsMeta + from ._settings import Settings # This is currently the only documented API @@ -75,7 +75,7 @@ def debug(self, msg, *, time=None, deep=None, extra=None) -> datetime: return self.log(DEBUG, msg, time=time, deep=deep, extra=extra) -def _set_log_file(settings: SettingsMeta) -> None: +def _set_log_file(settings: Settings) -> None: file = settings.logfile name = settings.logpath root = settings._root_logger @@ -88,11 +88,11 @@ def _set_log_file(settings: SettingsMeta) -> None: root.addHandler(h) -def _set_log_level(settings: SettingsMeta, level: int) -> None: +def _set_log_level(settings: Settings) -> None: root = settings._root_logger - root.setLevel(level) + root.setLevel(settings.verbosity.level) for h in list(root.handlers): - h.setLevel(level) + h.setLevel(settings.verbosity.level) class _LogFormatter(logging.Formatter): From 8c3c25ca2f594ddc6ad2fd9adaecbb6f1d6832c6 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 21 Apr 2026 17:45:56 +0200 Subject: [PATCH 2/3] prevent crash --- .vscode/settings.json | 4 ++-- src/scanpy/_settings/__init__.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9f5e9f8407..17d70d9420 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,6 @@ "python.testing.pytestArgs": ["-vv", "--color=yes", "--internet-tests"], "python.testing.pytestEnabled": true, "python.terminal.activateEnvironment": true, - "python-envs.defaultEnvManager": "ms-python.python:venv", - "python-envs.defaultPackageManager": "ms-python.python:pip", + "python-envs.defaultEnvManager": "flying-sheep.hatch:hatch", + "python-envs.defaultPackageManager": "flying-sheep.hatch:hatch", } diff --git a/src/scanpy/_settings/__init__.py b/src/scanpy/_settings/__init__.py index 9f3a898677..591c9831b7 100644 --- a/src/scanpy/_settings/__init__.py +++ b/src/scanpy/_settings/__init__.py @@ -296,5 +296,8 @@ def _set_figure_params( # noqa: PLR0913 def __str__(self) -> str: return "\n".join(f"{k} = {getattr(self, k)!r}" for k in type(self).model_fields) + def __hash__(self) -> int: + return hash((id(self),)) + settings = Settings() From 23adaa7819c6b079c34825cf72a83864ea105da3 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Thu, 23 Apr 2026 10:33:24 +0200 Subject: [PATCH 3/3] cleanup Co-authored-by: Copilot --- src/scanpy/_settings/__init__.py | 133 +++++++++++++++++++------------ 1 file changed, 80 insertions(+), 53 deletions(-) diff --git a/src/scanpy/_settings/__init__.py b/src/scanpy/_settings/__init__.py index 591c9831b7..ec1eefcd87 100644 --- a/src/scanpy/_settings/__init__.py +++ b/src/scanpy/_settings/__init__.py @@ -1,12 +1,19 @@ from __future__ import annotations import sys +from itertools import chain from pathlib import Path from time import time -from typing import TYPE_CHECKING, Annotated, Literal, TextIO +from typing import TYPE_CHECKING, Annotated, Literal, Protocol, runtime_checkable import pydantic_settings -from pydantic import AfterValidator +from pydantic import ( + AfterValidator, + ConfigDict, + computed_field, + field_validator, + model_validator, +) from .. import logging from .._compat import deprecated @@ -14,8 +21,20 @@ from .presets import Default, Preset from .verbosity import Verbosity +if sys.version_info >= (3, 14): + from io import Writer +else: + + @runtime_checkable + class Writer[T: str | bytes](Protocol): + def write(self, data: T, /) -> int: ... + + if TYPE_CHECKING: from collections.abc import Iterable + from typing import Self, TextIO + + from pydantic import ModelWrapValidatorHandler from .verbosity import _VerbosityName @@ -45,12 +64,11 @@ def _is_run_from_ipython() -> bool: # `type` is only here because of https://github.com/astral-sh/ruff/issues/20225 class Settings(pydantic_settings.BaseSettings): + model_config = ConfigDict(validate_assignment=True) + def model_post_init(self, context: object) -> None: # logging - self._verbosity = Verbosity.warning self._root_logger = _RootLogger(logging.WARNING) - self._logfile = _default_logfile() - self._logpath = None _set_log_level(self) _set_log_file(self) @@ -65,10 +83,20 @@ def model_post_init(self, context: object) -> None: """Preset to use.""" # logging - _verbosity: Verbosity + verbosity: Verbosity = Verbosity.warning + """Verbosity level (default :attr:`Verbosity.warning`).""" + _root_logger: _RootLogger - _logfile: TextIO - _logpath: Path | None + + logfile: Writer[str] = _default_logfile() + """The open file to write logs to. + + Set it to a :class:`~pathlib.Path` or :class:`str` to open a new one. + The default `None` corresponds to :obj:`sys.stdout` in jupyter notebooks + and to :obj:`sys.stderr` otherwise. + + For backwards compatibility, setting it to `''` behaves like setting it to `None`. + """ # rest N_PCS: int = 50 @@ -144,15 +172,28 @@ def model_post_init(self, context: object) -> None: _previous_memory_usage: int """Stores the previous memory usage.""" + @computed_field @property - def verbosity(self) -> Verbosity: - """Verbosity level (default :attr:`Verbosity.warning`).""" - return self._verbosity + def logpath(self) -> Path | None: + """The file path `logfile` was set to.""" + if self.logfile is _default_logfile(): + return None + if (name := getattr(self.logfile, "name", None)) is None: + return None + return Path(name) - @verbosity.setter - def _set_verbosity(self, verbosity: Verbosity | _VerbosityName | int) -> None: + @logpath.setter + def logpath(self, path: Path | None) -> None: + self.logfile = _default_logfile() if path is None else path.open("a") + + @field_validator("verbosity", mode="before") + @classmethod + def _check_verbosity( + cls, verbosity: Verbosity | _VerbosityName | int, / + ) -> Verbosity: + """Lenient conversion of verbosity from `int` or level name.""" try: - self._verbosity = ( + return ( Verbosity[verbosity.lower()] if isinstance(verbosity, str) else Verbosity(verbosity) @@ -163,45 +204,28 @@ def _set_verbosity(self, verbosity: Verbosity | _VerbosityName | int) -> None: f"Accepted string values are: {Verbosity.__members__.keys()}" ) raise ValueError(msg) from None - _set_log_level(self) - - @property - def logpath(self) -> Path | None: - """The file path `logfile` was set to.""" - return self._logpath - - @logpath.setter - def logpath(self, logpath: Path | str | None) -> None: - if logpath is None: - self.logfile = None - self._logpath = None - return - # set via “file object” branch of logfile.setter - self.logfile = Path(logpath).open("a") # noqa: SIM115 - self._logpath = Path(logpath) - - @property - def logfile(self) -> TextIO: - """The open file to write logs to. - Set it to a :class:`~pathlib.Path` or :class:`str` to open a new one. - The default `None` corresponds to :obj:`sys.stdout` in jupyter notebooks - and to :obj:`sys.stderr` otherwise. - - For backwards compatibility, setting it to `''` behaves like setting it to `None`. - """ - return self._logfile - - @logfile.setter - def logfile(self, logfile: Path | str | TextIO | None) -> None: - if not logfile: # "" or None - logfile = _default_logfile() - if isinstance(logfile, Path | str): - self.logpath = logfile - return - self._logfile = logfile - self._logpath = None - _set_log_file(self) + @model_validator(mode="wrap") + @classmethod + def _set_verbosity( + cls, data: object, handler: ModelWrapValidatorHandler[Self] + ) -> Self: + """Side effect of setting the verbosity.""" + self = handler(data) + if isinstance(data, dict) and "verbosity" in data: + _set_log_level(self) + return self + + @model_validator(mode="wrap") + @classmethod + def _set_log_file( + cls, data: object, handler: ModelWrapValidatorHandler[Self] + ) -> Self: + """Side effect of setting the logfile.""" + self = handler(data) + if isinstance(data, dict) and "logfile" in data: + _set_log_file(self) + return self # -------------------------------------------------------------------------------- # Functions @@ -294,7 +318,10 @@ def _set_figure_params( # noqa: PLR0913 self._frameon = frameon def __str__(self) -> str: - return "\n".join(f"{k} = {getattr(self, k)!r}" for k in type(self).model_fields) + return "\n".join( + f"{k} = {getattr(self, k)!r}" + for k in chain(type(self).model_fields, type(self).model_computed_fields) + ) def __hash__(self) -> int: return hash((id(self),))