Feature: 支持 PEP 695 类型别名 (#3621)

This commit is contained in:
Ju4tCode
2025-08-07 14:54:22 +08:00
committed by GitHub
parent 56f52f2c9f
commit 0d8b81614a
16 changed files with 232 additions and 9 deletions

View File

@ -7,13 +7,14 @@ FrontMatter:
""" """
import inspect 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 loguru import logger
from nonebot.compat import ModelField from nonebot.compat import ModelField
from nonebot.exception import TypeMisMatch 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: 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}' f'Unknown ForwardRef["{param.annotation}"] for parameter {param.name}'
) )
return inspect.Parameter.empty return inspect.Parameter.empty
if is_type_alias_type(annotation):
# Python 3.12+ supports PEP 695 TypeAliasType
annotation = cast(TypeAliasType, annotation).__value__
return annotation return annotation

View File

@ -99,6 +99,17 @@ def is_none_type(type_: type[t.Any]) -> bool:
return type_ in NONE_TYPES 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( def evaluate_forwardref(
ref: t.ForwardRef, globalns: dict[str, t.Any], localns: dict[str, t.Any] ref: t.ForwardRef, globalns: dict[str, t.Any], localns: dict[str, t.Any]
) -> t.Any: ) -> t.Any:

View File

@ -89,13 +89,15 @@ def generic_check_issubclass(
特别的: 特别的:
- 如果 cls 是 `typing.TypeVar` 类型,
则会检查其 `__bound__` 或 `__constraints__`
是否是 class_or_tuple 中一个类型的子类或 None。
- 如果 cls 是 `typing.Union` 或 `types.UnionType` 类型, - 如果 cls 是 `typing.Union` 或 `types.UnionType` 类型,
则会检查其中的所有类型是否是 class_or_tuple 中一个类型的子类或 None。 则会检查其中的所有类型是否是 class_or_tuple 中一个类型的子类或 None。
- 如果 cls 是 `typing.Literal` 类型, - 如果 cls 是 `typing.Literal` 类型,
则会检查其中的所有值是否是 class_or_tuple 中一个类型的实例。 则会检查其中的所有值是否是 class_or_tuple 中一个类型的实例。
- 如果 cls 是 `typing.TypeVar` 类型, - 如果 cls 是 `typing.List`、`typing.Dict` 等泛型类型,
则会检查其 `__bound__` 或 `__constraints__` 则会检查其原始类型是否是 class_or_tuple 中一个类型的子类。
是否是 class_or_tuple 中一个类型的子类或 None。
""" """
# if the target is a TypeVar, we check it first # if the target is a TypeVar, we check it first
if isinstance(cls, TypeVar): if isinstance(cls, TypeVar):

View File

@ -21,7 +21,7 @@ dependencies = [
"pygtrie >=2.4.1, <3.0.0", "pygtrie >=2.4.1, <3.0.0",
"exceptiongroup >=1.2.2, <2.0.0", "exceptiongroup >=1.2.2, <2.0.0",
"python-dotenv >=0.21.0, <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'", "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", "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" pythonPlatform = "All"
defineConstant = { PYDANTIC_V2 = true } defineConstant = { PYDANTIC_V2 = true }
executionEnvironments = [ executionEnvironments = [
{ root = "./tests/python_3_12", pythonVersion = "3.12", extraPaths = [
"./",
] },
{ root = "./tests", extraPaths = [ { root = "./tests", extraPaths = [
"./", "./",
] }, ] },

View File

@ -2,6 +2,7 @@ from collections.abc import Generator
from functools import wraps from functools import wraps
import os import os
from pathlib import Path from pathlib import Path
import sys
import threading import threading
from typing import TYPE_CHECKING, Callable, TypeVar from typing import TYPE_CHECKING, Callable, TypeVar
from typing_extensions import ParamSpec from typing_extensions import ParamSpec
@ -67,7 +68,14 @@ def run_once(func: Callable[P, R]) -> Callable[P, R]:
@run_once @run_once
def load_plugin(anyio_backend, nonebug_init: None) -> set["Plugin"]: def load_plugin(anyio_backend, nonebug_init: None) -> set["Plugin"]:
# preload global plugins # 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) @pytest.fixture(scope="session", autouse=True)

View File

@ -0,0 +1,7 @@
from pathlib import Path
from nonebot import load_plugins
_sub_plugins = set()
_sub_plugins |= load_plugins(str(Path(__file__).parent))

View File

@ -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

View File

@ -0,0 +1,7 @@
from nonebot.adapters import Bot
type AliasedBot = Bot
async def get_aliased_bot(b: AliasedBot) -> Bot:
return b

View File

@ -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

View File

@ -0,0 +1,7 @@
from nonebot.adapters import Event
type AliasedEvent = Event
async def aliased_event(e: AliasedEvent) -> Event:
return e

View File

@ -0,0 +1,5 @@
type AliasedException = Exception
async def aliased_exc(e: AliasedException) -> Exception:
return e

View File

@ -0,0 +1,7 @@
from nonebot.matcher import Matcher
type AliasedMatcher = Matcher
async def aliased_matcher(m: AliasedMatcher) -> Matcher:
return m

View File

@ -0,0 +1,7 @@
from nonebot.typing import T_State
type AliasedState = T_State
async def aliased_state(x: AliasedState) -> T_State:
return x

View File

@ -0,0 +1,3 @@
[tool.ruff]
extend = "../pyproject.toml"
target-version = "py312"

View File

@ -1,5 +1,6 @@
from contextlib import suppress from contextlib import suppress
import re import re
import sys
from exceptiongroup import BaseExceptionGroup from exceptiongroup import BaseExceptionGroup
from nonebug import App from nonebug import App
@ -156,6 +157,22 @@ async def test_depend(app: App):
ctx.should_return(1) 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 @pytest.mark.anyio
async def test_bot(app: App): async def test_bot(app: App):
from plugins.param.param_bot import ( from plugins.param.param_bot import (
@ -221,6 +238,19 @@ async def test_bot(app: App):
app.test_dependent(not_bot, allow_types=[BotParam]) 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 @pytest.mark.anyio
async def test_event(app: App): async def test_event(app: App):
from plugins.param.param_event import ( from plugins.param.param_event import (
@ -310,6 +340,21 @@ async def test_event(app: App):
ctx.should_return(fake_event.is_tome()) 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 @pytest.mark.anyio
async def test_state(app: App): async def test_state(app: App):
from plugins.param.param_state import ( from plugins.param.param_state import (
@ -461,6 +506,37 @@ async def test_state(app: App):
ctx.should_return(fake_state[KEYWORD_KEY]) 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<type>.*?),(?P<arg>.*?)\]", "[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 @pytest.mark.anyio
async def test_matcher(app: App): async def test_matcher(app: App):
from plugins.param.param_matcher import ( from plugins.param.param_matcher import (
@ -573,6 +649,20 @@ async def test_matcher(app: App):
ctx.should_return(False) 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 @pytest.mark.anyio
async def test_arg(app: App): async def test_arg(app: App):
from plugins.param.param_arg import ( from plugins.param.param_arg import (
@ -642,11 +732,28 @@ async def test_arg(app: App):
ctx.should_return(message.extract_plain_text()) 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 @pytest.mark.anyio
async def test_exception(app: App): async def test_exception(app: App):
from plugins.param.param_exception import exc, legacy_exc from plugins.param.param_exception import exc, legacy_exc
exception = ValueError("test") exception = ValueError("test")
async with app.test_dependent(exc, allow_types=[ExceptionParam]) as ctx: async with app.test_dependent(exc, allow_types=[ExceptionParam]) as ctx:
ctx.pass_params(exception=exception) ctx.pass_params(exception=exception)
ctx.should_return(exception) ctx.should_return(exception)
@ -656,6 +763,20 @@ async def test_exception(app: App):
ctx.should_return(exception) 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 @pytest.mark.anyio
async def test_default(app: App): async def test_default(app: App):
from plugins.param.param_default import default from plugins.param.param_default import default

4
uv.lock generated
View File

@ -1005,7 +1005,7 @@ name = "importlib-metadata"
version = "8.7.0" version = "8.7.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ 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" } 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 = [ wheels = [
@ -1350,7 +1350,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=0.21.0,<2.0.0" }, { name = "python-dotenv", specifier = ">=0.21.0,<2.0.0" },
{ name = "quart", marker = "extra == 'quart'", specifier = ">=0.18.0,<1.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 = "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 == '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 == 'fastapi'", specifier = ">=0.20.0,<1.0.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'quart'", specifier = ">=0.20.0,<1.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'quart'", specifier = ">=0.20.0,<1.0.0" },