From 0d8b81614ac92f4e24388e4f336e900fecf884c9 Mon Sep 17 00:00:00 2001 From: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> Date: Thu, 7 Aug 2025 14:54:22 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20Feature:=20=E6=94=AF=E6=8C=81=20PE?= =?UTF-8?q?P=20695=20=E7=B1=BB=E5=9E=8B=E5=88=AB=E5=90=8D=20(#3621)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot/dependencies/utils.py | 8 +- nonebot/typing.py | 11 ++ nonebot/utils.py | 8 +- pyproject.toml | 5 +- tests/conftest.py | 10 +- .../plugins/aliased_param/__init__.py | 7 + .../plugins/aliased_param/param_arg.py | 10 ++ .../plugins/aliased_param/param_bot.py | 7 + .../plugins/aliased_param/param_depend.py | 21 +++ .../plugins/aliased_param/param_event.py | 7 + .../plugins/aliased_param/param_exception.py | 5 + .../plugins/aliased_param/param_matcher.py | 7 + .../plugins/aliased_param/param_state.py | 7 + tests/python_3_12/pyproject.toml | 3 + tests/test_param.py | 121 ++++++++++++++++++ uv.lock | 4 +- 16 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 tests/python_3_12/plugins/aliased_param/__init__.py create mode 100644 tests/python_3_12/plugins/aliased_param/param_arg.py create mode 100644 tests/python_3_12/plugins/aliased_param/param_bot.py create mode 100644 tests/python_3_12/plugins/aliased_param/param_depend.py create mode 100644 tests/python_3_12/plugins/aliased_param/param_event.py create mode 100644 tests/python_3_12/plugins/aliased_param/param_exception.py create mode 100644 tests/python_3_12/plugins/aliased_param/param_matcher.py create mode 100644 tests/python_3_12/plugins/aliased_param/param_state.py create mode 100644 tests/python_3_12/pyproject.toml diff --git a/nonebot/dependencies/utils.py b/nonebot/dependencies/utils.py index 35954a18..fad996fa 100644 --- a/nonebot/dependencies/utils.py +++ b/nonebot/dependencies/utils.py @@ -7,13 +7,14 @@ FrontMatter: """ import inspect -from typing import Any, Callable, ForwardRef +from typing import Any, Callable, ForwardRef, cast +from typing_extensions import TypeAliasType from loguru import logger from nonebot.compat import ModelField from nonebot.exception import TypeMisMatch -from nonebot.typing import evaluate_forwardref +from nonebot.typing import evaluate_forwardref, is_type_alias_type def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: @@ -46,6 +47,9 @@ def get_typed_annotation(param: inspect.Parameter, globalns: dict[str, Any]) -> f'Unknown ForwardRef["{param.annotation}"] for parameter {param.name}' ) return inspect.Parameter.empty + if is_type_alias_type(annotation): + # Python 3.12+ supports PEP 695 TypeAliasType + annotation = cast(TypeAliasType, annotation).__value__ return annotation diff --git a/nonebot/typing.py b/nonebot/typing.py index b4937d6e..534af1f1 100644 --- a/nonebot/typing.py +++ b/nonebot/typing.py @@ -99,6 +99,17 @@ def is_none_type(type_: type[t.Any]) -> bool: return type_ in NONE_TYPES +if sys.version_info < (3, 12): + + def is_type_alias_type(type_: type[t.Any]) -> bool: + """判断是否是 TypeAliasType 类型""" + return isinstance(type_, t_ext.TypeAliasType) +else: + + def is_type_alias_type(type_: type[t.Any]) -> bool: + return isinstance(type_, (t.TypeAliasType, t_ext.TypeAliasType)) + + def evaluate_forwardref( ref: t.ForwardRef, globalns: dict[str, t.Any], localns: dict[str, t.Any] ) -> t.Any: diff --git a/nonebot/utils.py b/nonebot/utils.py index 0e90b1dd..5869fdbe 100644 --- a/nonebot/utils.py +++ b/nonebot/utils.py @@ -89,13 +89,15 @@ def generic_check_issubclass( 特别的: + - 如果 cls 是 `typing.TypeVar` 类型, + 则会检查其 `__bound__` 或 `__constraints__` + 是否是 class_or_tuple 中一个类型的子类或 None。 - 如果 cls 是 `typing.Union` 或 `types.UnionType` 类型, 则会检查其中的所有类型是否是 class_or_tuple 中一个类型的子类或 None。 - 如果 cls 是 `typing.Literal` 类型, 则会检查其中的所有值是否是 class_or_tuple 中一个类型的实例。 - - 如果 cls 是 `typing.TypeVar` 类型, - 则会检查其 `__bound__` 或 `__constraints__` - 是否是 class_or_tuple 中一个类型的子类或 None。 + - 如果 cls 是 `typing.List`、`typing.Dict` 等泛型类型, + 则会检查其原始类型是否是 class_or_tuple 中一个类型的子类。 """ # if the target is a TypeVar, we check it first if isinstance(cls, TypeVar): diff --git a/pyproject.toml b/pyproject.toml index e8d51159..ac6df4b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "pygtrie >=2.4.1, <3.0.0", "exceptiongroup >=1.2.2, <2.0.0", "python-dotenv >=0.21.0, <2.0.0", - "typing-extensions >=4.4.0, <5.0.0", + "typing-extensions >=4.6.0, <5.0.0", "tomli >=2.0.1, <3.0.0; python_version < '3.11'", "pydantic >=1.10.0, <3.0.0, !=2.5.0, !=2.5.1, !=2.10.0, !=2.10.1", ] @@ -129,6 +129,9 @@ pythonVersion = "3.9" pythonPlatform = "All" defineConstant = { PYDANTIC_V2 = true } executionEnvironments = [ + { root = "./tests/python_3_12", pythonVersion = "3.12", extraPaths = [ + "./", + ] }, { root = "./tests", extraPaths = [ "./", ] }, diff --git a/tests/conftest.py b/tests/conftest.py index 7028f032..d523c774 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from collections.abc import Generator from functools import wraps import os from pathlib import Path +import sys import threading from typing import TYPE_CHECKING, Callable, TypeVar from typing_extensions import ParamSpec @@ -67,7 +68,14 @@ def run_once(func: Callable[P, R]) -> Callable[P, R]: @run_once def load_plugin(anyio_backend, nonebug_init: None) -> set["Plugin"]: # preload global plugins - return nonebot.load_plugins(str(Path(__file__).parent / "plugins")) + plugins: set["Plugin"] = set() + plugins |= nonebot.load_plugins(str(Path(__file__).parent / "plugins")) + if sys.version_info >= (3, 12): + # preload python 3.12 plugins + plugins |= nonebot.load_plugins( + str(Path(__file__).parent / "python_3_12" / "plugins") + ) + return plugins @pytest.fixture(scope="session", autouse=True) diff --git a/tests/python_3_12/plugins/aliased_param/__init__.py b/tests/python_3_12/plugins/aliased_param/__init__.py new file mode 100644 index 00000000..f084d7f1 --- /dev/null +++ b/tests/python_3_12/plugins/aliased_param/__init__.py @@ -0,0 +1,7 @@ +from pathlib import Path + +from nonebot import load_plugins + +_sub_plugins = set() + +_sub_plugins |= load_plugins(str(Path(__file__).parent)) diff --git a/tests/python_3_12/plugins/aliased_param/param_arg.py b/tests/python_3_12/plugins/aliased_param/param_arg.py new file mode 100644 index 00000000..abcb8011 --- /dev/null +++ b/tests/python_3_12/plugins/aliased_param/param_arg.py @@ -0,0 +1,10 @@ +from typing import Annotated + +from nonebot.adapters import Message +from nonebot.params import Arg + +type AliasedArg = Annotated[Message, Arg()] + + +async def aliased_arg(key: AliasedArg) -> Message: + return key diff --git a/tests/python_3_12/plugins/aliased_param/param_bot.py b/tests/python_3_12/plugins/aliased_param/param_bot.py new file mode 100644 index 00000000..0252a4dc --- /dev/null +++ b/tests/python_3_12/plugins/aliased_param/param_bot.py @@ -0,0 +1,7 @@ +from nonebot.adapters import Bot + +type AliasedBot = Bot + + +async def get_aliased_bot(b: AliasedBot) -> Bot: + return b diff --git a/tests/python_3_12/plugins/aliased_param/param_depend.py b/tests/python_3_12/plugins/aliased_param/param_depend.py new file mode 100644 index 00000000..99781e09 --- /dev/null +++ b/tests/python_3_12/plugins/aliased_param/param_depend.py @@ -0,0 +1,21 @@ +from typing import Annotated + +from nonebot import on_message +from nonebot.params import Depends + +test_depends = on_message() + +runned = [] + + +def dependency(): + runned.append(1) + return 1 + + +type AliasedDepends = Annotated[int, Depends(dependency)] + + +@test_depends.handle() +async def aliased_depends(x: AliasedDepends): + return x diff --git a/tests/python_3_12/plugins/aliased_param/param_event.py b/tests/python_3_12/plugins/aliased_param/param_event.py new file mode 100644 index 00000000..334a71f9 --- /dev/null +++ b/tests/python_3_12/plugins/aliased_param/param_event.py @@ -0,0 +1,7 @@ +from nonebot.adapters import Event + +type AliasedEvent = Event + + +async def aliased_event(e: AliasedEvent) -> Event: + return e diff --git a/tests/python_3_12/plugins/aliased_param/param_exception.py b/tests/python_3_12/plugins/aliased_param/param_exception.py new file mode 100644 index 00000000..b9362140 --- /dev/null +++ b/tests/python_3_12/plugins/aliased_param/param_exception.py @@ -0,0 +1,5 @@ +type AliasedException = Exception + + +async def aliased_exc(e: AliasedException) -> Exception: + return e diff --git a/tests/python_3_12/plugins/aliased_param/param_matcher.py b/tests/python_3_12/plugins/aliased_param/param_matcher.py new file mode 100644 index 00000000..1f27e5de --- /dev/null +++ b/tests/python_3_12/plugins/aliased_param/param_matcher.py @@ -0,0 +1,7 @@ +from nonebot.matcher import Matcher + +type AliasedMatcher = Matcher + + +async def aliased_matcher(m: AliasedMatcher) -> Matcher: + return m diff --git a/tests/python_3_12/plugins/aliased_param/param_state.py b/tests/python_3_12/plugins/aliased_param/param_state.py new file mode 100644 index 00000000..ba5f7779 --- /dev/null +++ b/tests/python_3_12/plugins/aliased_param/param_state.py @@ -0,0 +1,7 @@ +from nonebot.typing import T_State + +type AliasedState = T_State + + +async def aliased_state(x: AliasedState) -> T_State: + return x diff --git a/tests/python_3_12/pyproject.toml b/tests/python_3_12/pyproject.toml new file mode 100644 index 00000000..595944bb --- /dev/null +++ b/tests/python_3_12/pyproject.toml @@ -0,0 +1,3 @@ +[tool.ruff] +extend = "../pyproject.toml" +target-version = "py312" diff --git a/tests/test_param.py b/tests/test_param.py index bf561c9e..9276865f 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -1,5 +1,6 @@ from contextlib import suppress import re +import sys from exceptiongroup import BaseExceptionGroup from nonebug import App @@ -156,6 +157,22 @@ async def test_depend(app: App): ctx.should_return(1) +@pytest.mark.anyio +@pytest.mark.skipif( + sys.version_info < (3, 12), reason="TypeAlias requires Python 3.12 or higher" +) +async def test_aliased_depend(app: App): + from python_3_12.plugins.aliased_param.param_depend import aliased_depends, runned + + async with app.test_dependent(aliased_depends, allow_types=[DependParam]) as ctx: + ctx.should_return(1) + + assert len(runned) == 1 + assert runned[0] == 1 + + runned.clear() + + @pytest.mark.anyio async def test_bot(app: App): from plugins.param.param_bot import ( @@ -221,6 +238,19 @@ async def test_bot(app: App): app.test_dependent(not_bot, allow_types=[BotParam]) +@pytest.mark.anyio +@pytest.mark.skipif( + sys.version_info < (3, 12), reason="TypeAlias requires Python 3.12 or higher" +) +async def test_aliased_bot(app: App): + from python_3_12.plugins.aliased_param.param_bot import get_aliased_bot + + async with app.test_dependent(get_aliased_bot, allow_types=[BotParam]) as ctx: + bot = ctx.create_bot() + ctx.pass_params(bot=bot) + ctx.should_return(bot) + + @pytest.mark.anyio async def test_event(app: App): from plugins.param.param_event import ( @@ -310,6 +340,21 @@ async def test_event(app: App): ctx.should_return(fake_event.is_tome()) +@pytest.mark.anyio +@pytest.mark.skipif( + sys.version_info < (3, 12), reason="TypeAlias requires Python 3.12 or higher" +) +async def test_aliased_event(app: App): + from python_3_12.plugins.aliased_param.param_event import aliased_event + + fake_message = FakeMessage("text") + fake_event = make_fake_event(_message=fake_message)() + + async with app.test_dependent(aliased_event, allow_types=[EventParam]) as ctx: + ctx.pass_params(event=fake_event) + ctx.should_return(fake_event) + + @pytest.mark.anyio async def test_state(app: App): from plugins.param.param_state import ( @@ -461,6 +506,37 @@ async def test_state(app: App): ctx.should_return(fake_state[KEYWORD_KEY]) +@pytest.mark.anyio +@pytest.mark.skipif( + sys.version_info < (3, 12), reason="TypeAlias requires Python 3.12 or higher" +) +async def test_aliased_state(app: App): + from python_3_12.plugins.aliased_param.param_state import aliased_state + + fake_message = FakeMessage("text") + fake_matched = re.match(r"\[cq:(?P.*?),(?P.*?)\]", "[cq:test,arg=value]") + fake_state = { + PREFIX_KEY: { + CMD_KEY: ("cmd",), + RAW_CMD_KEY: "/cmd", + CMD_START_KEY: "/", + CMD_ARG_KEY: fake_message, + CMD_WHITESPACE_KEY: " ", + }, + SHELL_ARGV: ["-h"], + SHELL_ARGS: {"help": True}, + REGEX_MATCHED: fake_matched, + STARTSWITH_KEY: "startswith", + ENDSWITH_KEY: "endswith", + FULLMATCH_KEY: "fullmatch", + KEYWORD_KEY: "keyword", + } + + async with app.test_dependent(aliased_state, allow_types=[StateParam]) as ctx: + ctx.pass_params(state=fake_state) + ctx.should_return(fake_state) + + @pytest.mark.anyio async def test_matcher(app: App): from plugins.param.param_matcher import ( @@ -573,6 +649,20 @@ async def test_matcher(app: App): ctx.should_return(False) +@pytest.mark.anyio +@pytest.mark.skipif( + sys.version_info < (3, 12), reason="TypeAlias requires Python 3.12 or higher" +) +async def test_aliased_matcher(app: App): + from python_3_12.plugins.aliased_param.param_matcher import aliased_matcher + + fake_matcher = Matcher() + + async with app.test_dependent(aliased_matcher, allow_types=[MatcherParam]) as ctx: + ctx.pass_params(matcher=fake_matcher) + ctx.should_return(fake_matcher) + + @pytest.mark.anyio async def test_arg(app: App): from plugins.param.param_arg import ( @@ -642,11 +732,28 @@ async def test_arg(app: App): ctx.should_return(message.extract_plain_text()) +@pytest.mark.anyio +@pytest.mark.skipif( + sys.version_info < (3, 12), reason="TypeAlias requires Python 3.12 or higher" +) +async def test_aliased_arg(app: App): + from python_3_12.plugins.aliased_param.param_arg import aliased_arg + + matcher = Matcher() + message = FakeMessage("text") + matcher.set_arg("key", message) + + async with app.test_dependent(aliased_arg, allow_types=[ArgParam]) as ctx: + ctx.pass_params(matcher=matcher) + ctx.should_return(message) + + @pytest.mark.anyio async def test_exception(app: App): from plugins.param.param_exception import exc, legacy_exc exception = ValueError("test") + async with app.test_dependent(exc, allow_types=[ExceptionParam]) as ctx: ctx.pass_params(exception=exception) ctx.should_return(exception) @@ -656,6 +763,20 @@ async def test_exception(app: App): ctx.should_return(exception) +@pytest.mark.anyio +@pytest.mark.skipif( + sys.version_info < (3, 12), reason="TypeAlias requires Python 3.12 or higher" +) +async def test_aliased_exception(app: App): + from python_3_12.plugins.aliased_param.param_exception import aliased_exc + + exception = ValueError("test") + + async with app.test_dependent(aliased_exc, allow_types=[ExceptionParam]) as ctx: + ctx.pass_params(exception=exception) + ctx.should_return(exception) + + @pytest.mark.anyio async def test_default(app: App): from plugins.param.param_default import default diff --git a/uv.lock b/uv.lock index 9d30f6d5..c2dfa168 100644 --- a/uv.lock +++ b/uv.lock @@ -1005,7 +1005,7 @@ name = "importlib-metadata" version = "8.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.10' or (extra == 'group-8-nonebot2-pydantic-v1' and extra == 'group-8-nonebot2-pydantic-v2')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ @@ -1350,7 +1350,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=0.21.0,<2.0.0" }, { name = "quart", marker = "extra == 'quart'", specifier = ">=0.18.0,<1.0.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1,<3.0.0" }, - { name = "typing-extensions", specifier = ">=4.4.0,<5.0.0" }, + { name = "typing-extensions", specifier = ">=4.6.0,<5.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.20.0,<1.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.20.0,<1.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'quart'", specifier = ">=0.20.0,<1.0.0" },