Repository: aiogram/aiogram
Pull request #1585 (2024-09-28 00:01:15)

Improved Scene handler and action inheritance mechanism
Enhanced the inheritance of handlers and actions in scenes. Refactored to eliminate the copying of previously connected handlers and actions from parent scenes. Now, handlers are dynamically rebuilt based on the current class, properly utilizing class inheritance and enabling handler overrides.

Fixes: #1583 


### TODO

- [ ] Tests
  - [x] More cases with inheritance
  - [x] More cases non-method handlers and actions

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aiogram/aiogram/pull/1585?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`080878b`)](https://app.codecov.io/gh/aiogram/aiogram/commit/080878be865ea63857ab67be1db6cd8f1bb9e503?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) to head [(`a57ebda`)](https://app.codecov.io/gh/aiogram/aiogram/commit/a57ebda06da0b3f4cb665d95df7edcb1f7da8230?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram).

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


[![Impacted file tree graph](https://app.codecov.io/gh/aiogram/aiogram/pull/1585/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/1585?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     #1585   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          468       468           
  Lines        11900     11896    -4     
=========================================
- Hits         11900     11896    -4     
```

| [Flag](https://app.codecov.io/gh/aiogram/aiogram/pull/1585/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/1585/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 with missing lines](https://app.codecov.io/gh/aiogram/aiogram/pull/1585?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [aiogram/fsm/scene.py](https://app.codecov.io/gh/aiogram/aiogram/pull/1585?src=pr&el=tree&filepath=aiogram%2Ffsm%2Fscene.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS9mc20vc2NlbmUucHk=) | `100.00% <100.00%> (ø)` | |

</details>

Patch:
diff --git a/CHANGES/1583.feature.rst b/CHANGES/1583.feature.rst
new file mode 100644
index 00000000..e060762e
--- /dev/null
+++ b/CHANGES/1583.feature.rst
@@ -0,0 +1,5 @@
+Enhanced the inheritance of handlers and actions in :ref:`Scenes <Scenes>`.
+Refactored to eliminate the copying of previously connected handlers and actions from parent scenes.
+Now, handlers are dynamically rebuilt based on the current class, properly utilizing class inheritance and enabling handler overrides.
+
+That's mean that you can now override handlers and actions in the child scene, instead of copying and duplicating them.
diff --git a/aiogram/fsm/scene.py b/aiogram/fsm/scene.py
index fe4de5c4..991b3472 100644
--- a/aiogram/fsm/scene.py
+++ b/aiogram/fsm/scene.py
@@ -316,10 +316,6 @@ class Scene:
             if not parent_scene_config:
                 continue
 
-            handlers.extend(parent_scene_config.handlers)
-            for action, action_handlers in parent_scene_config.actions.items():
-                actions[action].update(action_handlers)
-
             if reset_data_on_enter is None:
                 reset_data_on_enter = parent_scene_config.reset_data_on_enter
             if reset_history_on_enter is None:
@@ -327,9 +323,7 @@ class Scene:
             if callback_query_without_state is None:
                 callback_query_without_state = parent_scene_config.callback_query_without_state
 
-        for name in vars(cls):
-            value = getattr(cls, name)
-
+        for name, value in inspect.getmembers(cls):
             if scene_handlers := getattr(value, "__aiogram_handler__", None):
                 handlers.extend(scene_handlers)
             if isinstance(value, ObserverDecorator):


Test patch:
diff --git a/tests/test_fsm/test_scene.py b/tests/test_fsm/test_scene.py
index 2bb2cf0a..9c388a11 100644
--- a/tests/test_fsm/test_scene.py
+++ b/tests/test_fsm/test_scene.py
@@ -1,5 +1,4 @@
 import inspect
-import platform
 from datetime import datetime
 from unittest.mock import ANY, AsyncMock, patch
 
@@ -7,8 +6,9 @@ import pytest
 
 from aiogram import Dispatcher, F, Router
 from aiogram.dispatcher.event.bases import NextMiddlewareType
+from aiogram.enums import UpdateType
 from aiogram.exceptions import SceneException
-from aiogram.filters import StateFilter
+from aiogram.filters import Command, StateFilter
 from aiogram.fsm.context import FSMContext
 from aiogram.fsm.scene import (
     ActionContainer,
@@ -1545,3 +1545,120 @@ class TestSceneRegistry:
         assert isinstance(data["scenes"], ScenesManager)
         handler.assert_called_once_with(event, data)
         assert result == handler.return_value
+
+
+class TestSceneInheritance:
+    def test_inherit_handlers(self):
+        class ParentScene(Scene):
+            @on.message(Command("exit"))
+            async def command_exit(self, message: Message) -> None:
+                pass
+
+        class ChildScene(ParentScene):
+            pass
+
+        assert len(ParentScene.__scene_config__.handlers) == 1
+        assert len(ChildScene.__scene_config__.handlers) == 1
+
+        parent_command_handler = ParentScene.__scene_config__.handlers[0]
+        child_command_handler = ChildScene.__scene_config__.handlers[0]
+
+        assert parent_command_handler.handler is ParentScene.command_exit
+        assert child_command_handler.handler is ParentScene.command_exit
+
+    def test_override_handlers(self):
+        class ParentScene(Scene):
+            @on.message(Command("exit"))
+            async def command_exit(self, message: Message) -> int:
+                return 1
+
+        class ChildScene(ParentScene):
+            @on.message(Command("exit"))
+            async def command_exit(self, message: Message) -> int:
+                return 2
+
+        assert len(ParentScene.__scene_config__.handlers) == 1
+        assert len(ChildScene.__scene_config__.handlers) == 1
+
+        parent_command_handler = ParentScene.__scene_config__.handlers[0]
+        child_command_handler = ChildScene.__scene_config__.handlers[0]
+
+        assert parent_command_handler.handler is ParentScene.command_exit
+        assert child_command_handler.handler is not ParentScene.command_exit
+        assert child_command_handler.handler is ChildScene.command_exit
+
+    def test_inherit_actions(self):
+        class ParentScene(Scene):
+            @on.message.enter()
+            async def on_enter(self, message: Message) -> None:
+                pass
+
+        class ChildScene(ParentScene):
+            pass
+
+        parent_enter_action = ParentScene.__scene_config__.actions[SceneAction.enter][
+            UpdateType.MESSAGE
+        ]
+        child_enter_action = ChildScene.__scene_config__.actions[SceneAction.enter][
+            UpdateType.MESSAGE
+        ]
+
+        assert parent_enter_action.callback is ParentScene.on_enter
+        assert child_enter_action.callback is ParentScene.on_enter
+        assert child_enter_action.callback is ChildScene.on_enter
+
+    def test_override_actions(self):
+        class ParentScene(Scene):
+            @on.message.enter()
+            async def on_enter(self, message: Message) -> int:
+                return 1
+
+        class ChildScene(ParentScene):
+            @on.message.enter()
+            async def on_enter(self, message: Message) -> int:
+                return 2
+
+        parent_enter_action = ParentScene.__scene_config__.actions[SceneAction.enter][
+            UpdateType.MESSAGE
+        ]
+        child_enter_action = ChildScene.__scene_config__.actions[SceneAction.enter][
+            UpdateType.MESSAGE
+        ]
+
+        assert parent_enter_action.callback is ParentScene.on_enter
+        assert child_enter_action.callback is not ParentScene.on_enter
+        assert child_enter_action.callback is ChildScene.on_enter
+
+    def test_override_non_function_handler_by_function(self):
+        class ParentScene(Scene):
+            do_exit = on.message(Command("exit"), after=After.exit)
+
+        class ChildScene1(ParentScene):
+            pass
+
+        class ChildScene2(ParentScene):
+            do_exit = on.message(Command("exit"), after=After.back)
+
+        class ChildScene3(ParentScene):
+            @on.message(Command("exit"), after=After.back)
+            async def do_exit(self, message: Message) -> None:
+                pass
+
+        assert len(ParentScene.__scene_config__.handlers) == 1
+        assert len(ChildScene1.__scene_config__.handlers) == 1
+        assert len(ChildScene2.__scene_config__.handlers) == 1
+        assert len(ChildScene3.__scene_config__.handlers) == 1
+
+        parent_handler = ParentScene.__scene_config__.handlers[0]
+        child_1_handler = ChildScene1.__scene_config__.handlers[0]
+        child_2_handler = ChildScene2.__scene_config__.handlers[0]
+        child_3_handler = ChildScene3.__scene_config__.handlers[0]
+
+        assert child_1_handler.handler is parent_handler.handler
+        assert child_1_handler.after == parent_handler.after
+        assert child_1_handler.handler is _empty_handler
+
+        assert child_2_handler.after != parent_handler.after
+        assert child_2_handler.handler is _empty_handler
+
+        assert child_3_handler.handler is not _empty_handler