Repository: aiogram/aiogram
Pull request #1357 (2023-11-04 18:18:12)

new improved CallableMixin
# Description

new improved CallableMixin

## Type of change

Please delete options that are not relevant.

- [ ] Documentation (typos, code examples or any documentation update)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] This change requires a documentation update

# How Has This Been Tested?

all internal aiogram tests

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes


github-actions[bot]: # :x: Changelog is required!

You need to add a brief description of the changes to the `CHANGES` directory.

Changes file should be named like `<issue or PR number>.<category>.rst`,
example `1234.bugfix.rst` where `1234` is the PR or issue number and `bugfix` is the category.

The content of the file should be a brief description of the changes in
the PR in the format of a description of what has been done.

Possible categories are: `feature`, `bugfix`, `doc`, `removal` and `misc`.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aiogram/aiogram/pull/1357?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) Report
> Merging [#1357](https://app.codecov.io/gh/aiogram/aiogram/pull/1357?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (ed9e634) into [dev-3.x](https://app.codecov.io/gh/aiogram/aiogram/commit/a355daba49a6e03ee8ac306746d1c0406361986e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (a355dab) will **not change** coverage.
> Report is 1 commits behind head on dev-3.x.
> The diff coverage is `100.00%`.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/aiogram/aiogram/pull/1357/graphs/tree.svg?width=650&height=150&src=pr&token=cFqEr3p9MX&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)](https://app.codecov.io/gh/aiogram/aiogram/pull/1357?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)

```diff
@@            Coverage Diff            @@
##           dev-3.x     #1357   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          369       369           
  Lines         9444      9447    +3     
=========================================
+ Hits          9444      9447    +3     
```

| [Flag](https://app.codecov.io/gh/aiogram/aiogram/pull/1357/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/aiogram/aiogram/pull/1357/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | `100.00% <100.00%> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files](https://app.codecov.io/gh/aiogram/aiogram/pull/1357?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [aiogram/dispatcher/event/handler.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1357?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS9kaXNwYXRjaGVyL2V2ZW50L2hhbmRsZXIucHk=) | `100.00% <100.00%> (ø)` | |

</details>
RootShinobi: > I know you have a performance test and comparison of results, so please post that test and results as a comment to this pull request.
> 
> This can help advanced users understand what has actually been changed.

```python
from typing import Set, Dict, Any
import matplotlib.pyplot as plt
from aiogram.dispatcher.event.handler import CallableMixin, CallbackType
from dataclasses import field, dataclass
import inspect
from timeit import timeit


@dataclass
class CallableMixinEX:
    callback: CallbackType
    awaitable: bool = field(init=False)
    params: Set[str] = field(init=False)
    varkw: bool = field(init=False)

    def post_init(self) -> None:
        callback = inspect.unwrap(self.callback)
        self.awaitable = inspect.isawaitable(callback) or inspect.iscoroutinefunction(callback)
        spec = inspect.getfullargspec(callback)
        self.params = {*spec.args, *spec.kwonlyargs}
        self.varkw = spec.varkw is not None

    def _prepare_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
        if self.varkw:
            return kwargs

        return {k: kwargs[k] for k in self.params if k in kwargs}


REPEAT = 1000000
MAX_FUNC_PARAMS = 10
MAX_MIDDLEWARE_DATA = MAX_FUNC_PARAMS + 5
KEYS = tuple(f"param_{i}" for i in range(MAX_MIDDLEWARE_DATA))
X = []
X_EX = []
Y = []

for FUNC_PARAMS_SIZE in range(1, MAX_FUNC_PARAMS):
    PARAMS = ", ".join(KEYS[:FUNC_PARAMS_SIZE])
    FUNC = eval(f"lambda {PARAMS}: 1")
    for MIDDLEWARE_SIZE in range(FUNC_PARAMS_SIZE, MAX_MIDDLEWARE_DATA):
        MIDDLEWARE_DATA = dict(zip(KEYS[:MIDDLEWARE_SIZE], range(MIDDLEWARE_SIZE)))
        X.append(timeit(
            stmt="call._prepare_kwargs(MIDDLEWARE_DATA)",
            setup="call = CallableMixin(FUNC)",
            globals={**globals(), **locals()},
            number=REPEAT
        ))

        X_EX.append(timeit(
            stmt="call._prepare_kwargs(MIDDLEWARE_DATA)",
            setup="call = CallableMixinEX(FUNC)",
            globals={**globals(), **locals()},
            number=REPEAT
        ))
        Y.append(f"{FUNC_PARAMS_SIZE}x{MIDDLEWARE_SIZE}")

plt.plot(Y, X)
plt.plot(Y, X_EX)
plt.xlabel('func param size x middleware data size')
plt.ylabel('time, [s]')
plt.grid()
plt.xticks(rotation=-90)
plt.legend(['old', 'new'])
plt.show()
```

![photo](https://github.com/aiogram/aiogram/assets/111008396/5f26e1ea-5bcd-4202-84bf-f528dd9f4c80)


Patch:
diff --git a/CHANGES/1357.misc.rst b/CHANGES/1357.misc.rst
new file mode 100644
index 00000000..96822c1f
--- /dev/null
+++ b/CHANGES/1357.misc.rst
@@ -0,0 +1,1 @@
+Speeded up CallableMixin processing by caching references to nested objects and simplifying kwargs assembly.
diff --git a/CHANGES/1360.bugfix.rst b/CHANGES/1360.bugfix.rst
new file mode 100644
index 00000000..e02d0ea4
--- /dev/null
+++ b/CHANGES/1360.bugfix.rst
@@ -0,0 +1,1 @@
+Added current handler to filters, so that flags can be retrieved from it.
diff --git a/aiogram/dispatcher/event/handler.py b/aiogram/dispatcher/event/handler.py
index 2b71eed5..1045e667 100644
--- a/aiogram/dispatcher/event/handler.py
+++ b/aiogram/dispatcher/event/handler.py
@@ -4,7 +4,7 @@ import inspect
 import warnings
 from dataclasses import dataclass, field
 from functools import partial
-from typing import Any, Callable, Dict, List, Optional, Tuple
+from typing import Any, Callable, Dict, List, Optional, Tuple, Set
 
 from magic_filter.magic import MagicFilter as OriginalMagicFilter
 
@@ -21,20 +21,21 @@ CallbackType = Callable[..., Any]
 class CallableMixin:
     callback: CallbackType
     awaitable: bool = field(init=False)
-    spec: inspect.FullArgSpec = field(init=False)
+    params: Set[str] = field(init=False)
+    varkw: bool = field(init=False)
 
     def __post_init__(self) -> None:
         callback = inspect.unwrap(self.callback)
         self.awaitable = inspect.isawaitable(callback) or inspect.iscoroutinefunction(callback)
-        self.spec = inspect.getfullargspec(callback)
+        spec = inspect.getfullargspec(callback)
+        self.params = {*spec.args, *spec.kwonlyargs}
+        self.varkw = spec.varkw is not None
 
     def _prepare_kwargs(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
-        if self.spec.varkw:
+        if self.varkw:
             return kwargs
 
-        return {
-            k: v for k, v in kwargs.items() if k in self.spec.args or k in self.spec.kwonlyargs
-        }
+        return {k: kwargs[k] for k in self.params if k in kwargs}
 
     async def call(self, *args: Any, **kwargs: Any) -> Any:
         wrapped = partial(self.callback, *args, **self._prepare_kwargs(kwargs))
diff --git a/aiogram/dispatcher/event/telegram.py b/aiogram/dispatcher/event/telegram.py
index 4a370074..afa8938e 100644
--- a/aiogram/dispatcher/event/telegram.py
+++ b/aiogram/dispatcher/event/telegram.py
@@ -109,9 +109,10 @@ class TelegramEventObserver:
         Handler will be called when all its filters are pass.
         """
         for handler in self.handlers:
+            kwargs["handler"] = handler
             result, data = await handler.check(event, **kwargs)
             if result:
-                kwargs.update(data, handler=handler)
+                kwargs.update(data)
                 try:
                     wrapped_inner = self.outer_middleware.wrap_middlewares(
                         self._resolve_middlewares(),


Test patch:
diff --git a/tests/test_api/test_types/test_message.py b/tests/test_api/test_types/test_message.py
index 4e893d20..7efe6c89 100644
--- a/tests/test_api/test_types/test_message.py
+++ b/tests/test_api/test_types/test_message.py
@@ -36,6 +36,7 @@ from aiogram.methods import (
     UnpinChatMessage,
 )
 from aiogram.types import (
+    UNSET_PARSE_MODE,
     Animation,
     Audio,
     Chat,
@@ -75,7 +76,6 @@ from aiogram.types import (
     VideoNote,
     Voice,
     WebAppData,
-    UNSET_PARSE_MODE,
 )
 from aiogram.types.message import ContentType, Message
 
diff --git a/tests/test_dispatcher/test_event/test_handler.py b/tests/test_dispatcher/test_event/test_handler.py
index f7000d8e..c2b11cff 100644
--- a/tests/test_dispatcher/test_event/test_handler.py
+++ b/tests/test_dispatcher/test_event/test_handler.py
@@ -1,5 +1,5 @@
 import functools
-from typing import Any, Dict, Union
+from typing import Any, Dict, Union, Callable, Set
 
 import pytest
 from magic_filter import F as A
@@ -61,9 +61,9 @@ class TestCallableMixin:
             pytest.param(SyncCallable(), {"self", "foo", "bar", "baz"}),
         ],
     )
-    def test_init_args_spec(self, callback, args):
+    def test_init_args_spec(self, callback: Callable, args: Set[str]):
         obj = CallableMixin(callback)
-        assert set(obj.spec.args) == args
+        assert set(obj.params) == args
 
     def test_init_decorated(self):
         def decorator(func):
@@ -85,9 +85,9 @@ class TestCallableMixin:
         obj1 = CallableMixin(callback1)
         obj2 = CallableMixin(callback2)
 
-        assert set(obj1.spec.args) == {"foo", "bar", "baz"}
+        assert set(obj1.params) == {"foo", "bar", "baz"}
         assert obj1.callback == callback1
-        assert set(obj2.spec.args) == {"foo", "bar", "baz"}
+        assert set(obj2.params) == {"foo", "bar", "baz"}
         assert obj2.callback == callback2
 
     @pytest.mark.parametrize(
@@ -124,7 +124,9 @@ class TestCallableMixin:
             ),
         ],
     )
-    def test_prepare_kwargs(self, callback, kwargs, result):
+    def test_prepare_kwargs(
+        self, callback: Callable, kwargs: Dict[str, Any], result: Dict[str, Any]
+    ):
         obj = CallableMixin(callback)
         assert obj._prepare_kwargs(kwargs) == result