Skip to content

Commit efa3ac3

Browse files
✨ Feature: 允许插件从环境变量中读取配置项并支持 alias (#3673)
Co-authored-by: Ju4tCode <[email protected]>
1 parent ae3cf2a commit efa3ac3

File tree

6 files changed

+114
-40
lines changed

6 files changed

+114
-40
lines changed

nonebot/config.py

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class SettingsError(ValueError): ...
5151

5252

5353
class BaseSettingsSource(abc.ABC):
54-
def __init__(self, settings_cls: type["BaseSettings"]) -> None:
54+
def __init__(self, settings_cls: type[BaseModel]) -> None:
5555
self.settings_cls = settings_cls
5656

5757
@property
@@ -67,7 +67,7 @@ class InitSettingsSource(BaseSettingsSource):
6767
__slots__ = ("init_kwargs",)
6868

6969
def __init__(
70-
self, settings_cls: type["BaseSettings"], init_kwargs: dict[str, Any]
70+
self, settings_cls: type[BaseModel], init_kwargs: dict[str, Any]
7171
) -> None:
7272
self.init_kwargs = init_kwargs
7373
super().__init__(settings_cls)
@@ -82,33 +82,17 @@ def __repr__(self) -> str:
8282
class DotEnvSettingsSource(BaseSettingsSource):
8383
def __init__(
8484
self,
85-
settings_cls: type["BaseSettings"],
86-
env_file: Optional[DOTENV_TYPE] = ENV_FILE_SENTINEL,
87-
env_file_encoding: Optional[str] = None,
88-
case_sensitive: Optional[bool] = None,
85+
settings_cls: type[BaseModel],
86+
env_file: Optional[DOTENV_TYPE],
87+
env_file_encoding: str,
88+
case_sensitive: Optional[bool] = False,
8989
env_nested_delimiter: Optional[str] = None,
9090
) -> None:
9191
super().__init__(settings_cls)
92-
self.env_file = (
93-
env_file
94-
if env_file is not ENV_FILE_SENTINEL
95-
else self.config.get("env_file", (".env",))
96-
)
97-
self.env_file_encoding = (
98-
env_file_encoding
99-
if env_file_encoding is not None
100-
else self.config.get("env_file_encoding", "utf-8")
101-
)
102-
self.case_sensitive = (
103-
case_sensitive
104-
if case_sensitive is not None
105-
else self.config.get("case_sensitive", False)
106-
)
107-
self.env_nested_delimiter = (
108-
env_nested_delimiter
109-
if env_nested_delimiter is not None
110-
else self.config.get("env_nested_delimiter", None)
111-
)
92+
self.env_file = env_file
93+
self.env_file_encoding = env_file_encoding
94+
self.case_sensitive = case_sensitive
95+
self.env_nested_delimiter = env_nested_delimiter
11296

11397
def _apply_case_sensitive(self, var_name: str) -> str:
11498
return var_name if self.case_sensitive else var_name.lower()
@@ -212,12 +196,33 @@ def __call__(self) -> dict[str, Any]:
212196
for field in model_fields(self.settings_cls):
213197
field_name = field.name
214198
env_name = self._apply_case_sensitive(field_name)
199+
alias_name = field.field_info.alias
200+
alias_env_name = (
201+
None if alias_name is None else self._apply_case_sensitive(alias_name)
202+
)
203+
204+
# pydantic use alias name to validate if exist
205+
if alias_name is not None:
206+
field_name = alias_name
215207

216208
# try get values from env vars
217209
env_val = env_vars.get(env_name, PydanticUndefined)
210+
alias_env_val = (
211+
PydanticUndefined
212+
if alias_env_name is None
213+
else env_vars.get(alias_env_name, PydanticUndefined)
214+
)
215+
# alias env value has higher priority
216+
env_val = (
217+
env_val
218+
if isinstance(alias_env_val, PydanticUndefinedType)
219+
else alias_env_val
220+
)
218221
# delete from file vars when used
219222
if env_name in env_file_vars:
220223
del env_file_vars[env_name]
224+
if alias_env_name is not None and alias_env_name in env_file_vars:
225+
del env_file_vars[alias_env_name]
221226

222227
is_complex, allow_parse_failure = self._field_is_complex(field)
223228
if is_complex:
@@ -331,25 +336,48 @@ def __init__(
331336
_env_nested_delimiter: Optional[str] = None,
332337
**values: Any,
333338
) -> None:
339+
settings_config = model_config(__settings_self__.__class__)
340+
env_file = (
341+
_env_file
342+
if _env_file is not ENV_FILE_SENTINEL
343+
else settings_config.get("env_file", (".env",))
344+
)
345+
env_file_encoding = (
346+
_env_file_encoding
347+
if _env_file_encoding is not None
348+
else settings_config.get("env_file_encoding", "utf-8")
349+
)
350+
env_nested_delimiter = (
351+
_env_nested_delimiter
352+
if _env_nested_delimiter is not None
353+
else settings_config.get("env_nested_delimiter", None)
354+
)
355+
334356
super().__init__(
335357
**__settings_self__._settings_build_values(
358+
__settings_self__.__class__,
336359
values,
337-
env_file=_env_file,
338-
env_file_encoding=_env_file_encoding,
339-
env_nested_delimiter=_env_nested_delimiter,
360+
env_file=env_file,
361+
env_file_encoding=env_file_encoding,
362+
env_nested_delimiter=env_nested_delimiter,
340363
)
341364
)
342365

366+
__settings_self__._env_file = env_file
367+
__settings_self__._env_file_encoding = env_file_encoding
368+
__settings_self__._env_nested_delimiter = env_nested_delimiter
369+
370+
@staticmethod
343371
def _settings_build_values(
344-
self,
372+
settings_cls: type[BaseModel],
345373
init_kwargs: dict[str, Any],
346-
env_file: Optional[DOTENV_TYPE] = None,
347-
env_file_encoding: Optional[str] = None,
348-
env_nested_delimiter: Optional[str] = None,
374+
env_file: Optional[DOTENV_TYPE],
375+
env_file_encoding: str,
376+
env_nested_delimiter: Optional[str],
349377
) -> dict[str, Any]:
350-
init_settings = InitSettingsSource(self.__class__, init_kwargs=init_kwargs)
378+
init_settings = InitSettingsSource(settings_cls, init_kwargs=init_kwargs)
351379
env_settings = DotEnvSettingsSource(
352-
self.__class__,
380+
settings_cls,
353381
env_file=env_file,
354382
env_file_encoding=env_file_encoding,
355383
env_nested_delimiter=env_nested_delimiter,

nonebot/plugin/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747

4848
from nonebot import get_driver
4949
from nonebot.compat import model_dump, type_validate_python
50+
from nonebot.config import BaseSettings
5051

5152
C = TypeVar("C", bound=BaseModel)
5253

@@ -172,7 +173,17 @@ def get_available_plugin_names() -> set[str]:
172173

173174
def get_plugin_config(config: type[C]) -> C:
174175
"""从全局配置获取当前插件需要的配置项。"""
175-
return type_validate_python(config, model_dump(get_driver().config))
176+
global_config = get_driver().config
177+
return type_validate_python(
178+
config,
179+
BaseSettings._settings_build_values(
180+
config,
181+
model_dump(global_config),
182+
env_file=global_config._env_file,
183+
env_file_encoding=global_config._env_file_encoding,
184+
env_nested_delimiter=global_config._env_nested_delimiter,
185+
),
186+
)
176187

177188

178189
from .load import inherit_supported_adapters as inherit_supported_adapters

tests/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ NESTED__C__C=3
1010
NESTED__COMPLEX=[1, 2, 3]
1111
NESTED_INNER__A=1
1212
NESTED_INNER__B=2
13+
ALIAS_SIMPLE=aliased_simple
1314
OTHER_SIMPLE=simple
1415
OTHER_NESTED={"a": 1}
1516
OTHER_NESTED__B=2

tests/test_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class Config( # pyright: ignore[reportIncompatibleVariableOverride]
3737
complex_union: Union[int, list[int]] = 1
3838
nested: Simple = Simple()
3939
nested_inner: Simple = Simple()
40+
aliased_simple: str = Field(default="", alias="alias_simple")
4041

4142

4243
class ExampleWithoutDelimiter(Example):
@@ -85,6 +86,8 @@ def test_config_with_env():
8586
with pytest.raises(AttributeError):
8687
config.nested_inner__b
8788

89+
assert config.aliased_simple == "aliased_simple"
90+
8891
assert config.common_config == "common"
8992

9093
assert config.other_simple == "simple"

tests/test_plugin/test_get.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, Field
2+
import pytest
23

34
import nonebot
45
from nonebot.plugin import PluginManager, _managers
@@ -67,3 +68,27 @@ class Config(BaseModel):
6768
config = nonebot.get_plugin_config(Config)
6869
assert isinstance(config, Config)
6970
assert config.plugin_config == 1
71+
72+
73+
def test_get_plugin_config_with_env(monkeypatch: pytest.MonkeyPatch):
74+
monkeypatch.setenv("PLUGIN_CONFIG_ONE", "no_dummy_val")
75+
monkeypatch.setenv("PLUGIN_SUB_CONFIG__TWO", "two")
76+
monkeypatch.setenv("PLUGIN_CFG_THREE", "33")
77+
monkeypatch.setenv("CONFIG_FROM_INIT", "impossible")
78+
79+
class SubConfig(BaseModel):
80+
two: str = "dummy_val"
81+
82+
class Config(BaseModel):
83+
plugin_config: int
84+
plugin_config_one: str = "dummy_val"
85+
plugin_sub_config: SubConfig = Field(default_factory=SubConfig)
86+
plugin_config_three: int = Field(default=3, alias="plugin_cfg_three")
87+
config_from_init: str = "dummy_val"
88+
89+
config = nonebot.get_plugin_config(Config)
90+
assert config.plugin_config == 1
91+
assert config.plugin_config_one == "no_dummy_val"
92+
assert config.plugin_sub_config.two == "two"
93+
assert config.plugin_config_three == 33
94+
assert config.config_from_init == "init"

website/docs/appendices/config.mdx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export CUSTOM_CONFIG='config in environment variables'
8484
那最终 NoneBot 所读取的内容为环境变量中的内容,即 `config in environment variables`
8585

8686
:::caution 注意
87-
NoneBot 不会自发读取未被定义的配置项的环境变量,如果需要读取某一环境变量需要在 dotenv 配置文件中进行声明
87+
如果一个环境变量既不是 NoneBot [**内置配置项**](#内置配置项),也不是任何插件所定义的[**插件配置**](#插件配置),那么 NoneBot 不会自发读取该环境变量,需要在 dotenv 配置文件中先行声明
8888
:::
8989

9090
### dotenv 配置文件
@@ -242,11 +242,17 @@ weather = on_command(
242242

243243
这种方式可以简洁、高效地读取配置项,同时也可以设置默认值或者在运行时对配置项进行合法性检查,防止由于配置项导致的插件出错等情况出现。
244244

245-
:::tip 提示
245+
:::tip 可配置的事件响应优先级
246246
发布插件应该为自身的事件响应器提供可配置的优先级,以便插件使用者可以自定义多个插件间的响应顺序。
247247
:::
248248

249-
由于插件配置项是从全局配置中读取的,通常我们需要在配置项名称前面添加前缀名,以防止配置项冲突。例如在上方的示例中,我们就添加了配置项前缀 `weather_`。但是这样会导致在使用配置项时过长的变量名,因此我们可以使用 `pydantic``alias` 或者通过配置 scope 来简化配置项名称。这里我们以 scope 配置为例:
249+
:::tip 插件配置获取逻辑
250+
无论是否在 dotenv 文件中声明了插件配置项,使用 `get_plugin_config` 获取插件配置模型中定义的配置项时都遵循[**配置项的加载**](#配置项的加载)一节中的优先级顺序进行读取。
251+
:::
252+
253+
### 避免插件配置名称冲突
254+
255+
由于插件配置项是从全局配置和环境变量中读取的,通常我们需要在配置项名称前面添加前缀名,以防止配置项冲突。例如在上方的示例中,我们就添加了配置项前缀 `weather_`。但是这样会导致使用配置项时变量名过长,此时我们可以使用 `pydantic``alias` 或者通过配置 scope 来简化配置项名称。这里我们以 scope 配置为例:
250256

251257
```python title=weather/config.py
252258
from pydantic import BaseModel

0 commit comments

Comments
 (0)