Skip to content

Conversation

@leavesster
Copy link
Contributor

Summary

  • Extract common _add_callback_with_subscription() helper method
  • Extract common _remove_callback() helper method
  • Reduce code duplication in callback registration/removal

Problem

add_session_callback(), add_request_response_callback() had duplicate patterns for:

  • Checking if callback is callable
  • Creating callback list if not exists
  • Subscribing to topic on first callback

Solution

Extracted common logic into private helper methods that handle the subscription lifecycle.

Test Plan

  • Added tests for callback management in tests/test_mainframe_callbacks.py
  • All existing tests pass

The add/remove callback methods for session and request/response had
nearly identical logic. This refactoring:
- Extracts _add_callback() for common add logic with subscription
- Extracts _remove_callback() for common remove logic with cleanup
- Reduces code duplication while maintaining the same public API
- Adds comprehensive unit tests for callback management
Copilot AI review requested due to automatic review settings January 31, 2026 09:26
@coderabbitai
Copy link

coderabbitai bot commented Jan 31, 2026

概述

mainframe.py 中提取两个私有辅助方法来统一管理回调的添加和删除逻辑,现有公开方法重构以使用这些辅助方法。新增测试类覆盖回调管理的多个场景,包括有效订阅、验证错误和非现存回调的移除。

变更说明

内聚组 / 文件 摘要
回调管理重构
oocana/oocana/mainframe.py
引入 _add_callback_remove_callback 私有辅助方法集中处理回调逻辑,包括订阅注册、错误检查和清理。重构 add_request_response_callbackadd_session_callbackremove_request_response_callbackremove_session_callback 以使用新辅助方法,消除重复的内联代码。
回调管理测试
oocana/tests/test_mainframe_callbacks.py
新增 TestCallbackManagement 测试类,使用模拟 MQTT 客户端验证回调添加/移除、可调用性验证、订阅/取消订阅行为、非现存回调警告日志,以及多个回调的生命周期管理。

代码审查工作量估计

🎯 3 (中等) | ⏱️ ~20 分钟

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed 描述与变更集密切相关,详细说明了问题(代码重复)、解决方案(提取辅助方法)以及测试计划,充分解释了本次更改的目的和范围。
Title check ✅ Passed The title follows the required format <type>(<scope>): <subject> with 'fix' as type and 'oocana' as scope, accurately describing the main change of extracting common callback management logic.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors Mainframe callback registration/removal to reduce duplication by introducing shared helper methods, and adds unit tests for the callback lifecycle.

Changes:

  • Added _add_callback() helper to centralize callback registration + topic subscription on first callback.
  • Added _remove_callback() helper to centralize callback removal + topic unsubscribe on last callback.
  • Added tests/test_mainframe_callbacks.py coverage for add/remove behavior and warnings.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
oocana/oocana/mainframe.py Extracts shared callback add/remove logic and updates session + request/response callback methods to use it.
oocana/tests/test_mainframe_callbacks.py Adds unit tests covering callback registration/removal behavior and warning logging.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +31 to +34
# Verify subscribe was called with correct topic
expected_topic = f"session/{session_id}/request/{request_id}/response"
self.mock_client.message_callback_add.assert_called()

Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected_topic is computed but the test only asserts message_callback_add.assert_called() without checking the topic argument, so this test would still pass even if the code subscribed to the wrong topic. Assert the call args (e.g., that message_callback_add was called with expected_topic as the first parameter).

Copilot uses AI. Check for mistakes.
Comment on lines +113 to +119
# Subscribe should have been called only once (for first add)
call_count_before = self.mock_client.message_callback_add.call_count

# Remove second callback, should unsubscribe now
self.mainframe.remove_session_callback(session_id, callback2)

self.mock_client.unsubscribe.assert_called()
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call_count_before is assigned but never asserted/used, and the test doesn’t verify the intended behavior (“subscribe only once” / “don’t unsubscribe after removing the first callback”). Add assertions on message_callback_add.call_count and unsubscribe (e.g., unsubscribe.assert_not_called() after the first removal, then called after the second).

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +139
def _add_callback(
self,
callbacks_dict: dict[str, list[Callable]],
key: str,
topic: str,
callback: Callable[[Any], Any]
) -> None:
"""Generic method to add a callback with subscription management.

Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description references _add_callback_with_subscription() / _remove_callback() helper names, but the implementation introduces _add_callback() / _remove_callback(). Please align the PR description (or rename the helper) so reviewers/users aren’t looking for the wrong method names.

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +152
self.subscribe(topic, lambda payload: [cb(payload) for cb in callbacks_dict[key].copy()])

Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subscribed lambda reads callbacks_dict[key] directly; if the last callback is removed (key deleted) while the MQTT network thread is dispatching a message for this topic, this can raise a KeyError and break message handling. Consider making the handler resilient (e.g., read callbacks_dict.get(key, []) and early-return when missing) and/or ensure cleanup order avoids deleting the key before the message callback is removed.

Suggested change
self.subscribe(topic, lambda payload: [cb(payload) for cb in callbacks_dict[key].copy()])
def _dispatch_payload(payload, _callbacks_dict=callbacks_dict, _key=key):
callbacks = _callbacks_dict.get(_key)
if not callbacks:
return
for cb in callbacks.copy():
cb(payload)
self.subscribe(topic, _dispatch_payload)

Copilot uses AI. Check for mistakes.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@oocana/oocana/mainframe.py`:
- Around line 149-176: The current subscription handler closes over
callbacks_dict[key] and can raise KeyError if the last callback is removed
concurrently; update the subscribe call (the lambda created where
callbacks_dict[key] is set) to safely capture the callbacks list via
callbacks_dict.get(key, []) and copy it into a local variable, then iterate with
an explicit for-loop calling each cb(payload). Also ensure the removal logic in
_remove_callback remains unchanged except to rely on this safe access pattern so
late-arriving messages won't access callbacks_dict[key] after del; reference the
subscribe call, the lambda using callbacks_dict[key].copy(), and the
_remove_callback method when making this change.

In `@oocana/tests/test_mainframe_callbacks.py`:
- Around line 101-119: In test_multiple_callbacks_for_same_session, remove the
unused local variable call_count_before and add an explicit assertion that
message_callback_add was called exactly once after adding two callbacks (use
self.mock_client.message_callback_add.assert_called_once() or assertEqual on
call_count) before removing the second callback, then assert unsubscribe was
called after the second removal; reference the test function name and the mocked
methods self.mock_client.message_callback_add and self.mock_client.unsubscribe
and the class methods add_session_callback/remove_session_callback to locate
where to update the test.
- Around line 45-59: 在这两个测试中传入的字符串目的是触发运行时 ValueError,但静态检查器在 CI 报告了
reportArgumentType;在调用 add_session_callback 和 add_request_response_callback
的那两行将非可调用字面量保留并在参数上添加显式类型忽略注释以屏蔽静态检查器(例如在
mainframe.add_session_callback(session_id, "not a callable") 和
mainframe.add_request_response_callback("session", "request", "not a callable")
所在行分别添加 "# type: ignore[reportArgumentType]" 或通用 "# type:
ignore"),以便仅测试运行时行为而不触发类型检查错误。
- Around line 23-34: The test creates expected_topic but never uses it and only
asserts message_callback_add was called; update
test_add_request_response_callback to assert the exact topic and callback were
passed by calling
self.mock_client.message_callback_add.assert_called_with(expected_topic,
callback) (or include any additional args/kwargs if the real call includes them)
so the assertion verifies the correct topic string produced by
add_request_response_callback and the callback reference.

Comment on lines +149 to +176
if key not in callbacks_dict:
callbacks_dict[key] = []
self.subscribe(topic, lambda payload: [cb(payload) for cb in callbacks_dict[key].copy()])

callbacks_dict[key].append(callback)

def _remove_callback(
self,
callbacks_dict: dict[str, list[Callable]],
key: str,
topic: str,
callback: Callable[[Any], Any],
error_context: str
) -> None:
"""Generic method to remove a callback with subscription cleanup.

Args:
callbacks_dict: The dictionary storing callbacks
key: The key in the callbacks dict
topic: The MQTT topic to unsubscribe from
callback: The callback function to remove
error_context: Context string for warning message
"""
if key in callbacks_dict and callback in callbacks_dict[key]:
callbacks_dict[key].remove(callback)
if len(callbacks_dict[key]) == 0:
del callbacks_dict[key]
self.unsubscribe(topic)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

避免回调移除后触发 KeyError

当最后一个回调被移除后会 del callbacks_dict[key],若此时仍有滞后消息进入订阅回调,callbacks_dict[key] 会抛出 KeyError,导致 MQTT 回调异常。建议用 get 安全读取并用显式循环执行回调。

🛠️ 建议修复
-            self.subscribe(topic, lambda payload: [cb(payload) for cb in callbacks_dict[key].copy()])
+            def _dispatch(payload):
+                for cb in list(callbacks_dict.get(key, [])):
+                    cb(payload)
+            self.subscribe(topic, _dispatch)
🤖 Prompt for AI Agents
In `@oocana/oocana/mainframe.py` around lines 149 - 176, The current subscription
handler closes over callbacks_dict[key] and can raise KeyError if the last
callback is removed concurrently; update the subscribe call (the lambda created
where callbacks_dict[key] is set) to safely capture the callbacks list via
callbacks_dict.get(key, []) and copy it into a local variable, then iterate with
an explicit for-loop calling each cb(payload). Also ensure the removal logic in
_remove_callback remains unchanged except to rely on this safe access pattern so
late-arriving messages won't access callbacks_dict[key] after del; reference the
subscribe call, the lambda using callbacks_dict[key].copy(), and the
_remove_callback method when making this change.

Comment on lines +23 to +34
def test_add_request_response_callback(self):
"""Test adding a request response callback."""
session_id = 'test-session'
request_id = 'test-request'
callback = MagicMock()

self.mainframe.add_request_response_callback(session_id, request_id, callback)

# Verify subscribe was called with correct topic
expected_topic = f"session/{session_id}/request/{request_id}/response"
self.mock_client.message_callback_add.assert_called()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

用 expected_topic 做断言,避免无用变量和弱断言

当前 expected_topic 未被使用,且只断言 “被调用” 无法确保 topic 正确。建议直接断言 topic。

🧾 建议修复
-        self.mock_client.message_callback_add.assert_called()
+        self.mock_client.message_callback_add.assert_called()
+        self.assertEqual(self.mock_client.message_callback_add.call_args[0][0], expected_topic)
🧰 Tools
🪛 Ruff (0.14.14)

[error] 32-32: Local variable expected_topic is assigned to but never used

Remove assignment to unused variable expected_topic

(F841)

🤖 Prompt for AI Agents
In `@oocana/tests/test_mainframe_callbacks.py` around lines 23 - 34, The test
creates expected_topic but never uses it and only asserts message_callback_add
was called; update test_add_request_response_callback to assert the exact topic
and callback were passed by calling
self.mock_client.message_callback_add.assert_called_with(expected_topic,
callback) (or include any additional args/kwargs if the real call includes them)
so the assertion verifies the correct topic string produced by
add_request_response_callback and the callback reference.

Comment on lines +45 to +59
def test_add_callback_requires_callable(self):
"""Test that non-callable raises ValueError."""
session_id = 'test-session'

with self.assertRaises(ValueError) as context:
self.mainframe.add_session_callback(session_id, "not a callable")

self.assertIn("callable", str(context.exception))

def test_add_request_response_callback_requires_callable(self):
"""Test that non-callable raises ValueError for request response callback."""
with self.assertRaises(ValueError) as context:
self.mainframe.add_request_response_callback("session", "request", "not a callable")

self.assertIn("callable", str(context.exception))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

类型检查失败:传入非可调用值需显式忽略

CI 已报 reportArgumentType,这里是为了验证运行时校验,建议在测试里显式忽略类型检查。

🧾 建议修复
-            self.mainframe.add_session_callback(session_id, "not a callable")
+            self.mainframe.add_session_callback(session_id, "not a callable")  # type: ignore[arg-type]
@@
-            self.mainframe.add_request_response_callback("session", "request", "not a callable")
+            self.mainframe.add_request_response_callback("session", "request", "not a callable")  # type: ignore[arg-type]
🧰 Tools
🪛 GitHub Actions: layer

[error] 50-50: Argument of type "Literal['not a callable']" cannot be assigned to parameter "callback" of type "(dict[Unknown, Unknown]) -> Any" in function "add_session_callback". Type "Literal['not a callable']" is not assignable to type "(dict[Unknown, Unknown]) -> Any" (reportArgumentType)


[error] 57-57: Argument of type "Literal['not a callable']" cannot be assigned to parameter "callback" of type "(Any) -> Any" in function "add_request_response_callback". Type "Literal['not a callable']" is not assignable to type "(Any) -> Any" (reportArgumentType)

🪛 GitHub Actions: pr

[error] 50-50: Argument of type "Literal['not a callable']" cannot be assigned to parameter "callback" of type "(dict[Unknown, Unknown]) -> Any" in function "add_session_callback" (reportArgumentType)


[error] 57-57: Argument of type "Literal['not a callable']" cannot be assigned to parameter "callback" of type "(Any) -> Any" in function "add_request_response_callback" (reportArgumentType)

🤖 Prompt for AI Agents
In `@oocana/tests/test_mainframe_callbacks.py` around lines 45 - 59,
在这两个测试中传入的字符串目的是触发运行时 ValueError,但静态检查器在 CI 报告了 reportArgumentType;在调用
add_session_callback 和 add_request_response_callback
的那两行将非可调用字面量保留并在参数上添加显式类型忽略注释以屏蔽静态检查器(例如在
mainframe.add_session_callback(session_id, "not a callable") 和
mainframe.add_request_response_callback("session", "request", "not a callable")
所在行分别添加 "# type: ignore[reportArgumentType]" 或通用 "# type:
ignore"),以便仅测试运行时行为而不触发类型检查错误。

Comment on lines +101 to +119
def test_multiple_callbacks_for_same_session(self):
"""Test that multiple callbacks can be added for the same session."""
session_id = 'test-session'
callback1 = MagicMock()
callback2 = MagicMock()

self.mainframe.add_session_callback(session_id, callback1)
self.mainframe.add_session_callback(session_id, callback2)

# Remove first callback, should not unsubscribe yet
self.mainframe.remove_session_callback(session_id, callback1)

# Subscribe should have been called only once (for first add)
call_count_before = self.mock_client.message_callback_add.call_count

# Remove second callback, should unsubscribe now
self.mainframe.remove_session_callback(session_id, callback2)

self.mock_client.unsubscribe.assert_called()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

补上订阅次数断言,消除未使用变量

call_count_before 未使用,且当前未真正断言“只订阅一次”。建议直接断言调用次数。

🧾 建议修复
-        call_count_before = self.mock_client.message_callback_add.call_count
+        self.assertEqual(self.mock_client.message_callback_add.call_count, 1)
🧰 Tools
🪛 Ruff (0.14.14)

[error] 114-114: Local variable call_count_before is assigned to but never used

Remove assignment to unused variable call_count_before

(F841)

🤖 Prompt for AI Agents
In `@oocana/tests/test_mainframe_callbacks.py` around lines 101 - 119, In
test_multiple_callbacks_for_same_session, remove the unused local variable
call_count_before and add an explicit assertion that message_callback_add was
called exactly once after adding two callbacks (use
self.mock_client.message_callback_add.assert_called_once() or assertEqual on
call_count) before removing the second callback, then assert unsubscribe was
called after the second removal; reference the test function name and the mocked
methods self.mock_client.message_callback_add and self.mock_client.unsubscribe
and the class methods add_session_callback/remove_session_callback to locate
where to update the test.

@leavesster leavesster changed the title fix: extract common callback management logic (DRY) fix(oocana): extract common callback management logic (DRY) Jan 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants