Fix handler registration order in Scene (#1642)
Some checks are pending
Tests / tests (macos-latest, 3.10) (push) Waiting to run
Tests / tests (macos-latest, 3.11) (push) Waiting to run
Tests / tests (macos-latest, 3.12) (push) Waiting to run
Tests / tests (macos-latest, 3.13) (push) Waiting to run
Tests / tests (macos-latest, 3.9) (push) Waiting to run
Tests / tests (ubuntu-latest, 3.10) (push) Waiting to run
Tests / tests (ubuntu-latest, 3.11) (push) Waiting to run
Tests / tests (ubuntu-latest, 3.12) (push) Waiting to run
Tests / tests (ubuntu-latest, 3.13) (push) Waiting to run
Tests / tests (ubuntu-latest, 3.9) (push) Waiting to run
Tests / tests (windows-latest, 3.10) (push) Waiting to run
Tests / tests (windows-latest, 3.11) (push) Waiting to run
Tests / tests (windows-latest, 3.12) (push) Waiting to run
Tests / tests (windows-latest, 3.13) (push) Waiting to run
Tests / tests (windows-latest, 3.9) (push) Waiting to run
Tests / pypy-tests (macos-latest, pypy3.10) (push) Waiting to run
Tests / pypy-tests (macos-latest, pypy3.9) (push) Waiting to run
Tests / pypy-tests (ubuntu-latest, pypy3.10) (push) Waiting to run
Tests / pypy-tests (ubuntu-latest, pypy3.9) (push) Waiting to run

* Fix handler registration order in `Scene`

Previously, `Scene` handlers were registered based on the sorted output of `inspect.getmembers`, causing incorrect execution order. Now, handlers are registered in the order they are defined in the class, ensuring reliable behavior and proper sequence when handling filters with varying specificity. Added test cases to validate the correct handler ordering.

* Add dynamic dataclass and class attribute resolvers

Introduced `dataclass_kwargs` to ensure compatibility with different Python versions and modular attribute handling. Added utilities for resolving class attributes dynamically, enhancing flexibility with MRO-based resolvers. Updated tests to verify new features and ensure proper functionality across various scenarios.

* Update changelog
This commit is contained in:
Alex Root Junior 2025-03-01 22:08:14 +02:00 committed by GitHub
parent e622ada2fc
commit 8b4976b3de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 397 additions and 10 deletions

View file

@ -1,8 +1,9 @@
from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Optional
from aiogram.utils.dataclass import dataclass_kwargs
if TYPE_CHECKING:
from aiogram.types import LinkPreviewOptions
@ -28,13 +29,7 @@ class Default:
return f"<{self}>"
_dataclass_properties: Dict[str, Any] = {}
if sys.version_info >= (3, 10):
# Speedup attribute access for dataclasses in Python 3.10+
_dataclass_properties.update({"slots": True, "kw_only": True})
@dataclass(**_dataclass_properties)
@dataclass(**dataclass_kwargs(slots=True, kw_only=True))
class DefaultBotProperties:
"""
Default bot properties.

View file

@ -20,6 +20,10 @@ from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State
from aiogram.fsm.storage.memory import MemoryStorageRecord
from aiogram.types import TelegramObject, Update
from aiogram.utils.class_attrs_resolver import (
ClassAttrsResolver,
get_sorted_mro_attrs_resolver,
)
class HistoryManager:
@ -214,6 +218,15 @@ class SceneConfig:
"""Reset scene history on enter"""
callback_query_without_state: Optional[bool] = None
"""Allow callback query without state"""
attrs_resolver: ClassAttrsResolver = get_sorted_mro_attrs_resolver
"""
Attributes resolver.
.. danger::
This attribute should only be changed when you know what you are doing.
.. versionadded:: 3.19.0
"""
async def _empty_handler(*args: Any, **kwargs: Any) -> None:
@ -302,6 +315,7 @@ class Scene:
reset_data_on_enter = kwargs.pop("reset_data_on_enter", None)
reset_history_on_enter = kwargs.pop("reset_history_on_enter", None)
callback_query_without_state = kwargs.pop("callback_query_without_state", None)
attrs_resolver = kwargs.pop("attrs_resolver", None)
super().__init_subclass__(**kwargs)
@ -322,8 +336,13 @@ class Scene:
reset_history_on_enter = parent_scene_config.reset_history_on_enter
if callback_query_without_state is None:
callback_query_without_state = parent_scene_config.callback_query_without_state
if attrs_resolver is None:
attrs_resolver = parent_scene_config.attrs_resolver
for name, value in inspect.getmembers(cls):
if attrs_resolver is None:
attrs_resolver = get_sorted_mro_attrs_resolver
for name, value in attrs_resolver(cls):
if scene_handlers := getattr(value, "__aiogram_handler__", None):
handlers.extend(scene_handlers)
if isinstance(value, ObserverDecorator):
@ -346,6 +365,7 @@ class Scene:
reset_data_on_enter=reset_data_on_enter,
reset_history_on_enter=reset_history_on_enter,
callback_query_without_state=callback_query_without_state,
attrs_resolver=attrs_resolver,
)
@classmethod

View file

@ -0,0 +1,86 @@
import inspect
from dataclasses import dataclass
from operator import itemgetter
from typing import Any, Generator, NamedTuple, Protocol
from aiogram.utils.dataclass import dataclass_kwargs
class ClassAttrsResolver(Protocol):
def __call__(self, cls: type) -> Generator[tuple[str, Any], None, None]: ...
def inspect_members_resolver(cls: type) -> Generator[tuple[str, Any], None, None]:
"""
Inspects and resolves attributes of a given class.
This function uses the `inspect.getmembers` utility to yield all attributes of
a provided class. The output is a generator that produces tuples containing
attribute names and their corresponding values. This function is suitable for
analyzing class attributes dynamically. However, it guarantees alphabetical
order of attributes.
:param cls: The class for which the attributes will be resolved.
:return: A generator yielding tuples containing attribute names and their values.
"""
yield from inspect.getmembers(cls)
def get_reversed_mro_unique_attrs_resolver(cls: type) -> Generator[tuple[str, Any], None, None]:
"""
Resolve and yield attributes from the reversed method resolution order (MRO) of a given class.
This function iterates through the reversed MRO of a class and yields attributes
that have not yet been encountered. It avoids duplicates by keeping track of
attribute names that have already been processed.
:param cls: The class for which the attributes will be resolved.
:return: A generator yielding tuples containing attribute names and their values.
"""
known_attrs = set()
for base in reversed(inspect.getmro(cls)):
for name, value in base.__dict__.items():
if name in known_attrs:
continue
yield name, value
known_attrs.add(name)
class _Position(NamedTuple):
in_mro: int
in_class: int
@dataclass(**dataclass_kwargs(slots=True))
class _AttributeContainer:
position: _Position
value: Any
def __lt__(self, other: "_AttributeContainer") -> bool:
return self.position < other.position
def get_sorted_mro_attrs_resolver(cls: type) -> Generator[tuple[str, Any], None, None]:
"""
Resolve and yield attributes from the method resolution order (MRO) of a given class.
Iterates through a class's method resolution order (MRO) and collects its attributes
along with their respective positions in the MRO and the class hierarchy. This generator
yields a tuple containing the name of each attribute and its associated value.
:param cls: The class for which the attributes will be resolved.
:return: A generator yielding tuples containing attribute names and their values.
"""
attributes: dict[str, _AttributeContainer] = {}
for position_in_mro, base in enumerate(inspect.getmro(cls)):
for position_in_class, (name, value) in enumerate(vars(base).items()):
position = _Position(position_in_mro, position_in_class)
if attribute := attributes.get(name):
attribute.position = position
continue
attributes[name] = _AttributeContainer(value=value, position=position)
for name, attribute in sorted(attributes.items(), key=itemgetter(1)):
yield name, attribute.value

View file

@ -0,0 +1,64 @@
"""
This module contains utility functions for working with dataclasses in Python.
DO NOT USE THIS MODULE DIRECTLY. IT IS INTENDED FOR INTERNAL USE ONLY.
"""
import sys
from typing import Any, Union
def dataclass_kwargs(
init: Union[bool, None] = None,
repr: Union[bool, None] = None,
eq: Union[bool, None] = None,
order: Union[bool, None] = None,
unsafe_hash: Union[bool, None] = None,
frozen: Union[bool, None] = None,
match_args: Union[bool, None] = None,
kw_only: Union[bool, None] = None,
slots: Union[bool, None] = None,
weakref_slot: Union[bool, None] = None,
) -> dict[str, Any]:
"""
Generates a dictionary of keyword arguments that can be passed to a Python
dataclass. This function allows specifying attributes related to the behavior
and configuration of dataclasses, including attributes added in specific
Python versions. This abstraction improves compatibility across different
Python versions by ensuring only the parameters supported by the current
version are included.
:return: A dictionary containing the specified dataclass configuration that
dynamically adapts to the current Python version.
"""
params = {}
# All versions
if init is not None:
params["init"] = init
if repr is not None:
params["repr"] = repr
if eq is not None:
params["eq"] = eq
if order is not None:
params["order"] = order
if unsafe_hash is not None:
params["unsafe_hash"] = unsafe_hash
if frozen is not None:
params["frozen"] = frozen
# Added in 3.10
if sys.version_info >= (3, 10):
if match_args is not None:
params["match_args"] = match_args
if kw_only is not None:
params["kw_only"] = kw_only
if slots is not None:
params["slots"] = slots
# Added in 3.11
if sys.version_info >= (3, 11):
if weakref_slot is not None:
params["weakref_slot"] = weakref_slot
return params