Skip to content

Commit 7960635

Browse files
ilia-katsflying-sheepCopilot
authored
implement a pydantic-settings-based Settings base class (#16)
Co-authored-by: Philipp A. <flying-sheep@web.de> Co-authored-by: Copilot <copilot@github.com>
1 parent df76cec commit 7960635

11 files changed

Lines changed: 384 additions & 18 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ repos:
4242
- id: mypy
4343
args: []
4444
additional_dependencies:
45+
- pydantic-settings
4546
- pytest
4647
- sphinx
4748
- sphinxcontrib-katex

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning][].
88
[keep a changelog]: https://keepachangelog.com/en/1.1.0/
99
[semantic versioning]: https://semver.org/spec/v2.0.0.html
1010

11+
## [0.0.4]
12+
13+
## Added
14+
15+
- A `Settings` base class that packages can inherit from for their settings. This is based
16+
on [Pydantic Settings](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/) and
17+
provides validation for settings values as well as loading settings from environment variables and
18+
`.env` files.
19+
1120
## [0.0.3]
1221

1322
## Added
@@ -25,6 +34,7 @@ and this project adheres to [Semantic Versioning][].
2534

2635
- Initial release
2736

37+
[0.0.4]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.4
2838
[0.0.3]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.3
2939
[0.0.2]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.2
3040
[0.0.1]: https://github.com/scverse/scverse-misc/releases/tag/v0.0.1

docs/api.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,38 @@
77

88
## Extensions
99

10-
``` {eval-rst}
10+
```{eval-rst}
1111
.. autosummary::
1212
:toctree: generated
1313
1414
make_register_namespace_decorator
1515
```
1616
Types used by the former:
17-
``` {eval-rst}
17+
```{eval-rst}
1818
.. autosummary::
1919
:toctree: generated
2020
2121
ExtensionNamespace
2222
```
2323

2424
## Deprecations
25-
``` {eval-rst}
25+
```{eval-rst}
2626
.. autosummary::
2727
:toctree: generated
2828
2929
deprecated
3030
Deprecation
3131
```
32+
33+
## Settings
34+
35+
```{eval-rst}
36+
.. toctree::
37+
:hidden:
38+
39+
api/settings
40+
41+
+---------------------------+----------------------------------+
42+
| :class:`Settings` () | Base class for package settings. |
43+
+---------------------------+----------------------------------+
44+
```

docs/api/settings.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
scverse\_misc.Settings
2+
======================
3+
4+
.. currentmodule:: scverse_misc
5+
6+
.. autoclass:: Settings
7+
8+
.. automethod:: override

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"scipy": ("https://docs.scipy.org/doc/scipy", None),
102102
"pandas": ("https://pandas.pydata.org/docs/", None),
103103
"scanpy": ("https://scanpy.readthedocs.io/en/stable/", None),
104+
"pydantic": ("https://pydantic.dev/docs/validation/", None),
104105
}
105106

106107
# List of patterns, relative to source directory, that match files and

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies = [
2626
"session-info2",
2727
"typing-extensions; python_version<'3.13'",
2828
]
29+
optional-dependencies.settings = [ "pydantic-settings", "python-dotenv" ]
2930
# https://docs.pypi.org/project_metadata/#project-urls
3031
urls.Documentation = "https://scverse-misc.readthedocs.io/"
3132
urls.Homepage = "https://github.com/scverse/scverse-misc"
@@ -36,12 +37,13 @@ dev = [
3637
"pre-commit",
3738
"twine>=4.0.2",
3839
]
39-
test = [ "coverage>=7.10", "numpy", "pytest" ]
40+
test = [ "coverage>=7.10", "numpy", "pytest", "scverse-misc[settings]", "sphinx" ]
4041
doc = [
4142
"ipykernel",
4243
"ipython",
4344
"myst-nb>=1.1",
4445
"pandas",
46+
"scverse-misc[settings]",
4547
"sphinx>=8.1",
4648
"sphinx-autodoc-typehints",
4749
"sphinx-book-theme>=1",
@@ -93,6 +95,7 @@ lint.select = [
9395
]
9496
lint.ignore = [
9597
"B008", # Errors from function calls in argument defaults. These are fine when the result is immutable.
98+
"C408", # `dict()` is sometimes nicer than `{}`
9699
"D100", # Missing docstring in public module
97100
"D104", # Missing docstring in public package
98101
"D105", # __magic__ methods are often self-explanatory, allow missing docstrings

src/scverse_misc/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
from contextlib import suppress
2+
13
from ._deprecated import Deprecation, deprecated
24
from ._extensions import ExtensionNamespace, make_register_namespace_decorator
35

46
__all__ = ["ExtensionNamespace", "make_register_namespace_decorator", "deprecated", "Deprecation"]
7+
8+
with suppress(ImportError):
9+
from ._settings import Settings
10+
11+
__all__.append("Settings")

src/scverse_misc/_settings.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
from __future__ import annotations
2+
3+
import textwrap
4+
import warnings
5+
from collections.abc import Generator
6+
from contextlib import contextmanager
7+
from types import GenericAlias
8+
from typing import Literal
9+
10+
import dotenv
11+
from pydantic.fields import FieldInfo
12+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
13+
14+
from ._utils import copy_func
15+
16+
17+
def _type_str(field: FieldInfo) -> str:
18+
return (
19+
field.annotation.__name__
20+
if isinstance(field.annotation, type) and not isinstance(field.annotation, GenericAlias)
21+
else str(field.annotation)
22+
)
23+
24+
25+
_docstring_template = """Allows users to customize settings for the `{package}` package.
26+
27+
Settings here will generally be for advanced use-cases and should be used with caution.
28+
29+
For setting an option use :func:`~{package}.{name}.override` (local) or set the attributes directly (global)
30+
i.e., `{package}.{name}.my_setting = foo`. For assignment by environment variable, use the variable name in
31+
all caps with `{env_prefix}` as the prefix before import of `{package}`.
32+
"""
33+
34+
35+
class Settings(BaseSettings):
36+
'''Base class for package settings.
37+
38+
This class can be subclassed by individual packages to get package-specific settings handling.
39+
Settings will be validated on assignment thanks to Pydantic. The class requires one argument
40+
`exported_object_name` and one optional argument `docstring_style`, which will be used to construct
41+
a suitable docstring (see the examples).
42+
43+
Both a settings instance and its `override` method should be added to the package documentation.
44+
45+
Thanks to Pydantic Settings, settings values will also be loaded from environment variables or `.env`
46+
files. Environment variables must be prefixex with `$PACKAGE_NAME_` to take effect, where `$PACKAGE_NAME`
47+
is the name of the package of the subclass. This can be overridden by passing `env_prefix=CUSTOMPREFIX`
48+
as class argument.
49+
50+
Examples:
51+
>>> from typing import Annotated
52+
... from pydantic import Field
53+
... from scverse_misc import Settings
54+
...
55+
...
56+
... class MySettings(Settings, exported_object_name="settings", docstring_style="numpy"):
57+
... eps: Annotated[float, Field(gt=0, lt=1)] = 1e-8
58+
... """Small epsilon for numerical stability."""
59+
...
60+
... use_optional_feature: bool = False
61+
... """Whether to use the optional feature."""
62+
...
63+
...
64+
... settings = MySettings()
65+
'''
66+
67+
@classmethod
68+
def settings_customise_sources(
69+
cls,
70+
settings_cls: type[BaseSettings],
71+
init_settings: PydanticBaseSettingsSource,
72+
env_settings: PydanticBaseSettingsSource,
73+
dotenv_settings: PydanticBaseSettingsSource,
74+
file_secret_settings: PydanticBaseSettingsSource,
75+
) -> tuple[PydanticBaseSettingsSource, ...]:
76+
return init_settings, env_settings, dotenv_settings
77+
78+
@staticmethod
79+
def _get_packagename(subcls: type[Settings]) -> str:
80+
package_name = subcls.__module__
81+
dotidx = package_name.find(".")
82+
if dotidx > -1:
83+
package_name = package_name[:dotidx]
84+
return package_name
85+
86+
def __init_subclass__(subcls, *, exported_object_name: str, docstring_style: Literal["google", "numpy"] = "google"):
87+
if (config := subcls.__dict__.get("model_config")) is not None:
88+
if not config.get("validate_assignment", True):
89+
warnings.warn("`validate_assignment=False` is not supported, overriding.", RuntimeWarning, stacklevel=2)
90+
if not config.get("use_attribute_docstrings", True):
91+
warnings.warn(
92+
"`use_attribute_docstrings=False` is not supported, overriding.", RuntimeWarning, stacklevel=2
93+
)
94+
if config.get("env_file") is not None:
95+
warnings.warn(
96+
"Setting a custom env_file location is not supported, overriding.", RuntimeWarning, stacklevel=2
97+
)
98+
else:
99+
config = SettingsConfigDict()
100+
101+
config["validate_assignment"] = True
102+
config["use_attribute_docstrings"] = True
103+
config["env_file"] = dotenv.find_dotenv()
104+
105+
if not config.get("env_prefix"):
106+
config["env_prefix"] = f"{__class__._get_packagename(subcls)}_" # type: ignore[name-defined] # https://github.com/python/mypy/issues/4177
107+
subcls.model_config = config
108+
109+
super().__init_subclass__()
110+
111+
@contextmanager
112+
def override(self, **kwargs: object) -> Generator[None]:
113+
"""Context manager for local setting overrides.
114+
115+
Subclasses will get a version with a docstring detailing the available parameters.
116+
"""
117+
oldsettings = {argname: getattr(self, argname) for argname in kwargs.keys()}
118+
try:
119+
for argname, argval in kwargs.items():
120+
setattr(self, argname, argval)
121+
yield
122+
finally:
123+
for argname, argval in reversed(oldsettings.items()):
124+
setattr(self, argname, argval)
125+
126+
@classmethod
127+
def __pydantic_init_subclass__( # type: ignore[override]
128+
subcls, *, exported_object_name: str, docstring_style: Literal["google", "numpy"] = "google"
129+
) -> None:
130+
subcls.__doc__ = (
131+
_docstring_template.format(
132+
package=__class__._get_packagename(subcls), # type: ignore[name-defined] # https://github.com/python/mypy/issues/4177
133+
name=exported_object_name,
134+
env_prefix=subcls.model_config["env_prefix"].upper(),
135+
)
136+
+ "\n\nThe following options are available:\n"
137+
)
138+
override_doc = "Provides local override via keyword arguments as a context manager.\n\n"
139+
if docstring_style == "google":
140+
override_doc += "Args:\n"
141+
else:
142+
override_doc += "Parameters\n----------\n"
143+
for fname, field in subcls.model_fields.items():
144+
subcls.__doc__ += f"""
145+
.. attribute:: {exported_object_name}.{fname}
146+
:type: {_type_str(field)}
147+
:value: {field.default!r}\n"""
148+
149+
description = f"(default `{field.default!r}`) "
150+
if field.description is not None:
151+
subcls.__doc__ += f"\n{textwrap.indent(field.description, ' ')}\n"
152+
description += field.description
153+
154+
if docstring_style == "google":
155+
override_doc += f""" {fname} ({_type_str(field)}): {textwrap.indent(description, " ")}\n"""
156+
else:
157+
override_doc += f"""
158+
{fname} : {_type_str(field)}
159+
{textwrap.indent(description, " ")}\n"""
160+
161+
subcls.override = copy_func( # type: ignore[method-assign,type-var]
162+
subcls.override,
163+
__doc__=override_doc,
164+
__module__=subcls.__module__,
165+
__qualname__=f"{subcls.__qualname__}.override",
166+
)

src/scverse_misc/_utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import functools
2+
import sys
3+
from functools import WRAPPER_ASSIGNMENTS
4+
from types import FunctionType
5+
from typing import ParamSpec, TypedDict, TypeVar, TypeVarTuple, Unpack, cast
6+
7+
8+
class Overrides(TypedDict, total=False):
9+
__module__: str
10+
__name__: str
11+
__qualname__: str
12+
__doc__: str
13+
# ≥3.14: __annotate__, <3.14: __annotations__
14+
__type_params__: tuple[TypeVar | TypeVarTuple | ParamSpec, ...]
15+
16+
17+
def copy_func[F: FunctionType](func: F, /, **overrides: Unpack[Overrides]) -> F:
18+
kw = dict(kwdefaults=func.__kwdefaults__) if sys.version_info >= (3, 13) else {}
19+
new = FunctionType(
20+
func.__code__, func.__globals__, name=func.__name__, argdefs=func.__defaults__, closure=func.__closure__, **kw
21+
)
22+
for key, value in overrides.items():
23+
setattr(new, key, value)
24+
copy = set(WRAPPER_ASSIGNMENTS) - overrides.keys()
25+
return cast("F", functools.update_wrapper(new, func, assigned=copy))

tests/test_deprecation_decorator.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,29 @@
66
from scverse_misc import Deprecation, deprecated
77

88

9-
@pytest.fixture(params=[None, "Test message."])
9+
@pytest.fixture(params=[pytest.param(None, id="no_message"), pytest.param("Test message.", id="message")])
1010
def msg(request: pytest.FixtureRequest) -> str | None:
1111
return cast(str | None, request.param)
1212

1313

1414
@pytest.fixture(
1515
params=[
16-
None,
17-
"Test function",
18-
"""Test function
19-
20-
This is a test.
21-
22-
Parameters
23-
----------
24-
foo
25-
bar
26-
bar
27-
baz
28-
""",
16+
pytest.param(None, id="no_docstring"),
17+
pytest.param("Test function", id="short"),
18+
pytest.param(
19+
"""Test function
20+
21+
This is a test.
22+
23+
Parameters
24+
----------
25+
foo
26+
bar
27+
bar
28+
baz
29+
""",
30+
id="long",
31+
),
2932
]
3033
)
3134
def docstring(request: pytest.FixtureRequest) -> str | None:

0 commit comments

Comments
 (0)