From dace63d9d226adef4eab35af271c25da56a2cc27 Mon Sep 17 00:00:00 2001 From: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> Date: Mon, 5 Feb 2024 14:00:49 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20Feature:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20Pydantic=20=E7=9B=B8=E5=85=B3=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E6=B3=95=20(#2563)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot/__init__.py | 2 ++ nonebot/compat.py | 16 ++++++++++ nonebot/plugin/__init__.py | 14 ++++++++- tests/.env.test | 1 + tests/test_compat.py | 12 +++++++ tests/test_plugin/test_get.py | 12 +++++++ website/docs/appendices/config.mdx | 38 +++++++++++++++++------ website/docs/developer/adapter-writing.md | 4 +-- website/docs/tutorial/application.md | 2 +- website/docs/tutorial/message.md | 11 +++---- 10 files changed, 93 insertions(+), 19 deletions(-) diff --git a/nonebot/__init__.py b/nonebot/__init__.py index 575fce53..e1d523a1 100644 --- a/nonebot/__init__.py +++ b/nonebot/__init__.py @@ -35,6 +35,7 @@ {ref}``get_loaded_plugins` ` - `get_available_plugin_names` => {ref}``get_available_plugin_names` ` +- `get_plugin_config` => {ref}``get_plugin_config` ` - `require` => {ref}``require` ` FrontMatter: @@ -352,6 +353,7 @@ from nonebot.plugin import load_from_json as load_from_json from nonebot.plugin import load_from_toml as load_from_toml from nonebot.plugin import load_all_plugins as load_all_plugins from nonebot.plugin import on_shell_command as on_shell_command +from nonebot.plugin import get_plugin_config as get_plugin_config from nonebot.plugin import get_loaded_plugins as get_loaded_plugins from nonebot.plugin import load_builtin_plugin as load_builtin_plugin from nonebot.plugin import load_builtin_plugins as load_builtin_plugins diff --git a/nonebot/compat.py b/nonebot/compat.py index c42861ee..96ec7bc0 100644 --- a/nonebot/compat.py +++ b/nonebot/compat.py @@ -12,6 +12,7 @@ from typing_extensions import Self, Annotated, is_typeddict from typing import ( TYPE_CHECKING, Any, + Set, Dict, List, Type, @@ -50,6 +51,7 @@ __all__ = ( "model_field_validate", "model_fields", "model_config", + "model_dump", "type_validate_python", "custom_validation", ) @@ -183,6 +185,13 @@ if PYDANTIC_V2: # pragma: pydantic-v2 """Get config of a model.""" return model.model_config + def model_dump( + model: BaseModel, + include: Optional[Set[str]] = None, + exclude: Optional[Set[str]] = None, + ) -> Dict[str, Any]: + return model.model_dump(include=include, exclude=exclude) + def type_validate_python(type_: Type[T], data: Any) -> T: """Validate data with given type.""" return TypeAdapter(type_).validate_python(data) @@ -321,6 +330,13 @@ else: # pragma: pydantic-v1 """Get config of a model.""" return model.__config__ + def model_dump( + model: BaseModel, + include: Optional[Set[str]] = None, + exclude: Optional[Set[str]] = None, + ) -> Dict[str, Any]: + return model.dict(include=include, exclude=exclude) + def type_validate_python(type_: Type[T], data: Any) -> T: """Validate data with given type.""" return parse_obj_as(type_, data) diff --git a/nonebot/plugin/__init__.py b/nonebot/plugin/__init__.py index d876428b..61d3fa35 100644 --- a/nonebot/plugin/__init__.py +++ b/nonebot/plugin/__init__.py @@ -39,7 +39,14 @@ FrontMatter: from itertools import chain from types import ModuleType from contextvars import ContextVar -from typing import Set, Dict, List, Tuple, Optional +from typing import Set, Dict, List, Type, Tuple, TypeVar, Optional + +from pydantic import BaseModel + +from nonebot import get_driver +from nonebot.compat import model_dump, type_validate_python + +C = TypeVar("C", bound=BaseModel) _plugins: Dict[str, "Plugin"] = {} _managers: List["PluginManager"] = [] @@ -108,6 +115,11 @@ def get_available_plugin_names() -> Set[str]: return {*chain.from_iterable(manager.available_plugins for manager in _managers)} +def get_plugin_config(config: Type[C]) -> C: + """从全局配置获取当前插件需要的配置项。""" + return type_validate_python(config, model_dump(get_driver().config)) + + from .on import on as on from .manager import PluginManager from .on import on_type as on_type diff --git a/tests/.env.test b/tests/.env.test index 6479747a..d84d3919 100644 --- a/tests/.env.test +++ b/tests/.env.test @@ -13,3 +13,4 @@ NESTED_MISSING_DICT__A=1 NESTED_MISSING_DICT__B__C=2 NOT_NESTED=some string NOT_NESTED__A=1 +PLUGIN_CONFIG=1 diff --git a/tests/test_compat.py b/tests/test_compat.py index fc263ea1..3c020312 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -2,12 +2,14 @@ from typing import Any from dataclasses import dataclass import pytest +from pydantic import BaseModel from nonebot.compat import ( DEFAULT_CONFIG, Required, FieldInfo, PydanticUndefined, + model_dump, custom_validation, type_validate_python, ) @@ -28,6 +30,16 @@ async def test_field_info(): assert FieldInfo(test="test").extra["test"] == "test" +@pytest.mark.asyncio +async def test_model_dump(): + class TestModel(BaseModel): + test1: int + test2: int + + assert model_dump(TestModel(test1=1, test2=2), include={"test1"}) == {"test1": 1} + assert model_dump(TestModel(test1=1, test2=2), exclude={"test1"}) == {"test2": 2} + + @pytest.mark.asyncio async def test_custom_validation(): called = [] diff --git a/tests/test_plugin/test_get.py b/tests/test_plugin/test_get.py index a63e8ef2..629f7f8d 100644 --- a/tests/test_plugin/test_get.py +++ b/tests/test_plugin/test_get.py @@ -1,4 +1,5 @@ import pytest +from pydantic import BaseModel import nonebot from nonebot.plugin import PluginManager, _managers @@ -35,3 +36,14 @@ async def test_get_available_plugin(): finally: _managers.clear() _managers.extend(old_managers) + + +@pytest.mark.asyncio +async def test_get_plugin_config(): + class Config(BaseModel): + plugin_config: int + + # check get plugin config + config = nonebot.get_plugin_config(Config) + assert isinstance(config, Config) + assert config.plugin_config == 1 diff --git a/website/docs/appendices/config.mdx b/website/docs/appendices/config.mdx index da90780b..96409a1c 100644 --- a/website/docs/appendices/config.mdx +++ b/website/docs/appendices/config.mdx @@ -19,6 +19,25 @@ NoneBot 使用 [`pydantic`](https://docs.pydantic.dev/) 以及 [`python-dotenv`] NoneBot 内置的配置项列表及含义可以在[内置配置项](#内置配置项)中查看。 +:::caution 注意 + +NoneBot 自 2.2.0 起兼容了 Pydantic v1 与 v2 版本,以下文档中 Pydantic 相关示例均采用 v2 版本用法。 + +如果在使用商店或其他第三方插件的过程中遇到 Pydantic 相关警告或报错,例如: + +```python +pydantic_core._pydantic_core.ValidationError: 1 validation error for Config + Input should be a valid dictionary or instance of Config [type=model_type, input_value=Config(...), input_type=Config] +``` + +请考虑降级 Pydantic 至 v1 版本: + +```bash +pip install --force-reinstall 'pydantic~=1.10' +``` + +::: + ## 配置项的加载 在 NoneBot 中,我们可以把配置途径分为 **直接传入**、**系统环境变量**、**dotenv 配置文件** 三种,其加载优先级依次由高到低。 @@ -182,18 +201,19 @@ superusers = config.superusers 在 NoneBot 中,我们使用强大高效的 `pydantic` 来定义配置模型,这个模型可以被用于配置的读取和类型检查等。例如在 `weather` 插件目录中新建 `config.py` 来定义一个模型: ```python title=weather/config.py -from pydantic import BaseModel, validator +from pydantic import BaseModel, field_validator class Config(BaseModel): weather_api_key: str weather_command_priority: int = 10 weather_plugin_enabled: bool = True - @validator("weather_command_priority") - def check_priority(cls, v): - if isinstance(v, int) and v >= 1: + @field_validator("weather_command_priority") + @classmethod + def check_priority(cls, v: int) -> int: + if v >= 1: return v - raise ValueError("weather command priority must be an integer and greater than 1") + raise ValueError("weather command priority must greater than 1") ``` 在 `config.py` 中,我们定义了一个 `Config` 类,它继承自 `pydantic.BaseModel`,并定义了一些配置项。在 `Config` 类中,我们还定义了一个 `check_priority` 方法,它用于检查 `weather_command_priority` 配置项的合法性。更多关于 `pydantic` 的编写方式,可以参考 [pydantic 官方文档](https://docs.pydantic.dev/)。 @@ -201,11 +221,11 @@ class Config(BaseModel): 在定义好配置模型后,我们可以在插件加载时获取全局配置,导入插件自身的配置模型并使用: ```python {5,11} title=weather/__init__.py -from nonebot import get_driver +from nonebot import get_plugin_config from .config import Config -plugin_config = Config.parse_obj(get_driver().config) +plugin_config = get_plugin_config(Config) weather = on_command( "天气", @@ -239,11 +259,11 @@ class Config(BaseModel): ``` ```python title=weather/__init__.py -from nonebot import get_driver +from nonebot import get_plugin_config from .config import Config -plugin_config = Config.parse_obj(get_driver().config).weather +plugin_config = get_plugin_config(Config).weather ``` 这样我们就可以省略插件配置项名称中的前缀 `weather_` 了。但需要注意的是,如果我们使用了 scope 配置,那么在配置文件中也需要使用 [`env_nested_delimiter` 格式](#配置项解析),例如: diff --git a/website/docs/developer/adapter-writing.md b/website/docs/developer/adapter-writing.md index 2cef3f82..1a739102 100644 --- a/website/docs/developer/adapter-writing.md +++ b/website/docs/developer/adapter-writing.md @@ -79,9 +79,9 @@ except Exception as e: 通常适配器需要一些配置项,例如平台连接密钥等。适配器的配置方法与[插件配置](../appendices/config#%E6%8F%92%E4%BB%B6%E9%85%8D%E7%BD%AE)类似,例如: ```python title=config.py -from pydantic import BaseModel, Extra +from pydantic import BaseModel -class Config(BaseModel, extra=Extra.ignore): +class Config(BaseModel): xxx_id: str xxx_token: str ``` diff --git a/website/docs/tutorial/application.md b/website/docs/tutorial/application.md index 60f2a594..2faa8b1a 100644 --- a/website/docs/tutorial/application.md +++ b/website/docs/tutorial/application.md @@ -56,7 +56,7 @@ options: ## 创建配置文件 -配置文件用于存放 NoneBot 运行所需要的配置项,使用 [`pydantic`](https://pydantic-docs.helpmanual.io/) 以及 [`python-dotenv`](https://saurabh-kumar.com/python-dotenv/) 来读取配置。配置项需符合 dotenv 格式,复杂类型数据需使用 JSON 格式填写。具体可选配置方式以及配置项详情参考[配置](../appendices/config.mdx)。 +配置文件用于存放 NoneBot 运行所需要的配置项,使用 [`pydantic`](https://docs.pydantic.dev/) 以及 [`python-dotenv`](https://saurabh-kumar.com/python-dotenv/) 来读取配置。配置项需符合 dotenv 格式,复杂类型数据需使用 JSON 格式填写。具体可选配置方式以及配置项详情参考[配置](../appendices/config.mdx)。 在**项目文件夹**中创建一个 `.env` 文本文件,并写入以下内容: diff --git a/website/docs/tutorial/message.md b/website/docs/tutorial/message.md index 50e7878b..86989c49 100644 --- a/website/docs/tutorial/message.md +++ b/website/docs/tutorial/message.md @@ -82,20 +82,19 @@ Message([MessageSegment.text("Hello, world!")]) #### 从字典数组构造 -`Message` 对象支持 Pydantic 自定义类型构造,可以使用 Pydantic 的 `parse_obj_as` 方法进行构造。 +`Message` 对象支持 Pydantic 自定义类型构造,可以使用 Pydantic 的 `TypeAdapter` 方法进行构造。 ```python -from pydantic import parse_obj_as +from pydantic import TypeAdapter from nonebot.adapters.console import Message, MessageSegment # 由字典构造消息段 -parse_obj_as( - MessageSegment, {"type": "text", "data": {"text": "text"}} +TypeAdapter(MessageSegment).validate_python( + {"type": "text", "data": {"text": "text"}} ) == MessageSegment.text("text") # 由字典数组构造消息序列 -parse_obj_as( - Message, +TypeAdapter(Message).validate_python( [MessageSegment.text("text"), {"type": "text", "data": {"text": "text"}}], ) == Message([MessageSegment.text("text"), MessageSegment.text("text")]) ```