Repository: aiogram/aiogram

Issue:
Scenes are effectively not inheritable
### Checklist

- [X] I am sure the error is coming from aiogram code
- [X] I have searched in the issue tracker for similar bug reports, including closed ones

### Operating system

Windows 10, Ubuntu 22.04

### Python version

3.12

### aiogram version

3.10.0

### Expected behavior

`@on.message(F.text)` in the child class overrides `@on.message(F.text)` from the parent class, rather than being added after it, thus becoming uncallable.

### Current behavior

Due to the way `ObserverDecorator._wrap_filter` is currently implemented (handlers with same filters from child class appended after handlers from parent class), scenes cannot be correctly inherited from each other to extend their functionality.

### Steps to reproduce

1. Create a scene with handler registered as `@on.message(F.text)`
2. Create a subclass with handler also registered as `@on.message(F.text)`.

### Code example

```python3
class ParentScene(Scene):
    @on.message(F.text)
    async def handle_text(self, message: Message) -> None:
        await message.answer('Always called')
        
        
class ChildScene(ParentScene):
    @on.message(F.text)
    async def handle_text(self, message: Message) -> None:
        await message.answer('Never called')
```


### Logs

_No response_

### Additional information

_No response_


Discussion:
This is not a bug, it is implemented as planned from the beginning.

I agree that the current behavior is a bit strange and confusing, it works the way it does because all handlers and actions are copied from the parent classes and only then own handlers are configured.

I changed this behavior in #1585, from now on inheritance should work as you expect, check it out in this branch.

Note that a method can be overridden by name only, the way inheritance works in Python, but not by filters set on handler. Filters should be reapplied to the method as in your example.

I'll need to write more tests to prove that everything works as expected in other cases, but it looks pretty good for now. After all the tests, I can publish it on PyPi as a new version.



Patch:
diff --git a/CHANGES/1583.feature.rst b/CHANGES/1583.feature.rst
new file mode 100644
index 0000000000..e060762e4e
--- /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 fe4de5c434..991b34720b 100644
--- a/aiogram/fsm/scene.py
+++ b/aiogram/fsm/scene.py
@@ -316,10 +316,6 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
             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 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
             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 2bb2cf0a75..9c388a117f 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 @@
 
 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 @@ async def test_scene_registry_middleware(self, bot: MockedBot):
         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