mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
refactor: enforce BasedPyright strict type checking (#5158)
This commit is contained in:
@@ -891,7 +891,8 @@ def test_drop_malformed_tool_calls_trims_response():
|
||||
tool_calls=[
|
||||
ToolCallRequest(id="1", name=None, arguments={}),
|
||||
ToolCallRequest(id="2", name="", arguments={}),
|
||||
ToolCallRequest(id="3", name="read_file", arguments={}),
|
||||
ToolCallRequest(id="3", name={"unexpected": "object"}, arguments={}),
|
||||
ToolCallRequest(id="4", name="read_file", arguments={}),
|
||||
],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
@@ -899,7 +900,7 @@ def test_drop_malformed_tool_calls_trims_response():
|
||||
assert [tc.name for tc in response.tool_calls] == ["read_file"]
|
||||
assert response.finish_reason == "tool_calls"
|
||||
assert response.should_execute_tools is True
|
||||
assert dropped == 2
|
||||
assert dropped == 3
|
||||
assert all_dropped is False
|
||||
assert orig == "tool_calls"
|
||||
|
||||
|
||||
@@ -1217,6 +1217,7 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
seen["force"] = force
|
||||
seen["config"] = self.config
|
||||
seen["bus"] = self.bus
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config())
|
||||
@@ -1229,6 +1230,7 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert seen["force"] is True
|
||||
assert isinstance(seen["bus"], MessageBus)
|
||||
|
||||
|
||||
def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
|
||||
|
||||
@@ -13,6 +13,7 @@ import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.cli import commands as cli_commands
|
||||
@@ -71,6 +72,26 @@ class _StopGatewayError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _GatewayAgentContractStub:
|
||||
"""Minimal stable AgentLoop surface required by gateway assembly tests."""
|
||||
|
||||
tools = ToolRegistry()
|
||||
|
||||
@staticmethod
|
||||
def pending_cron_job_ids_for_session(_session_key: str) -> set[str]:
|
||||
return set()
|
||||
|
||||
@staticmethod
|
||||
def pending_local_trigger_ids_for_session(_session_key: str) -> set[str]:
|
||||
return set()
|
||||
|
||||
async def submit_local_trigger_turn(
|
||||
self,
|
||||
_msg: InboundMessage,
|
||||
) -> OutboundMessage | None:
|
||||
return None
|
||||
|
||||
|
||||
def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
|
||||
class _FakeLoop:
|
||||
def __init__(self) -> None:
|
||||
@@ -1949,7 +1970,7 @@ def test_heartbeat_empty_response_still_retains_recent_messages(
|
||||
def register_system_job(self, _job: CronJob) -> None:
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
class _FakeAgentLoop:
|
||||
class _FakeAgentLoop(_GatewayAgentContractStub):
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
@@ -2615,7 +2636,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
||||
self.on_job = None
|
||||
seen["cron"] = self
|
||||
|
||||
class _FakeAgentLoop:
|
||||
class _FakeAgentLoop(_GatewayAgentContractStub):
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
@@ -2731,7 +2752,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
||||
def write_run_record(self, run_id: str, record: dict[str, object]) -> None:
|
||||
seen["run_records"].append((run_id, record))
|
||||
|
||||
class _FakeAgentLoop:
|
||||
class _FakeAgentLoop(_GatewayAgentContractStub):
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
@@ -2947,7 +2968,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
||||
def register_system_job(self, _job) -> None:
|
||||
return None
|
||||
|
||||
class _FakeAgentLoop:
|
||||
class _FakeAgentLoop(_GatewayAgentContractStub):
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
seen["agent_from_config_kwargs"] = extra
|
||||
@@ -3200,7 +3221,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
def flush_all(self) -> int:
|
||||
return 0
|
||||
|
||||
class _FakeAgentLoop:
|
||||
class _FakeAgentLoop(_GatewayAgentContractStub):
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
@@ -3393,7 +3414,7 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
|
||||
def flush_all(self) -> int:
|
||||
return 0
|
||||
|
||||
class _FakeAgentLoop:
|
||||
class _FakeAgentLoop(_GatewayAgentContractStub):
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
@@ -3492,7 +3513,7 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
||||
def flush_all(self) -> int:
|
||||
return 0
|
||||
|
||||
class _FakeAgentLoop:
|
||||
class _FakeAgentLoop(_GatewayAgentContractStub):
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
|
||||
@@ -231,6 +231,7 @@ def _build_runnable_dream(
|
||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
||||
process_direct=process_direct,
|
||||
dream_runtime=lambda: None,
|
||||
)
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
|
||||
return ctx, store
|
||||
@@ -317,6 +318,7 @@ async def test_dream_noop_batch_unlocks_following_history(tmp_path) -> None:
|
||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
||||
process_direct=process_direct,
|
||||
dream_runtime=lambda: None,
|
||||
)
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import Parameter, signature
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -13,6 +14,13 @@ from nanobot.command.builtin import (
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
|
||||
|
||||
def test_command_context_requires_loop_as_keyword_dependency() -> None:
|
||||
loop_parameter = signature(CommandContext).parameters["loop"]
|
||||
|
||||
assert loop_parameter.kind is Parameter.KEYWORD_ONLY
|
||||
assert loop_parameter.default is Parameter.empty
|
||||
|
||||
|
||||
class TestIsDispatchableCommand:
|
||||
"""Unit tests for the is_dispatchable_command() predicate."""
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from nanobot.config.loader import (
|
||||
_resolve_env_vars,
|
||||
load_config,
|
||||
resolve_config_env_vars,
|
||||
resolve_env_refs,
|
||||
save_config,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
@@ -50,6 +51,12 @@ class TestResolveEnvVars:
|
||||
_resolve_env_vars("${DOES_NOT_EXIST}")
|
||||
|
||||
|
||||
class TestResolveSingleEnvRefs:
|
||||
@pytest.mark.parametrize("value", [None, 42, True, {"key": "value"}])
|
||||
def test_non_string_values_pass_through_unchanged(self, value):
|
||||
assert resolve_env_refs(value) is value
|
||||
|
||||
|
||||
class TestResolveConfig:
|
||||
def test_resolves_env_vars_in_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TEST_API_KEY", "resolved-key")
|
||||
|
||||
@@ -65,6 +65,17 @@ def test_load_jobs_accepts_snake_case_schedule_and_run_history(tmp_path) -> None
|
||||
assert jobs[0].state.run_history[0].duration_ms == 12
|
||||
|
||||
|
||||
def test_cron_job_from_dict_rejects_malformed_run_history() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
CronJob.from_dict(
|
||||
{
|
||||
"id": "j1",
|
||||
"name": "t",
|
||||
"state": {"run_history": [None]},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_load_jobs_coerces_string_schedule_and_state_ms(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
store_path.parent.mkdir(parents=True)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.pairing import __all__ as pairing_all
|
||||
@@ -272,6 +274,23 @@ def test_load_treats_null_approved_and_pending_maps_as_empty(tmp_path, monkeypat
|
||||
assert store.get_approved("telegram") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[("approved", "corrupt"), ("pending", ["corrupt"])],
|
||||
)
|
||||
def test_load_treats_non_object_approved_and_pending_maps_as_empty(
|
||||
tmp_path, monkeypatch, field, value
|
||||
):
|
||||
path = tmp_path / "pairing.json"
|
||||
payload = {"approved": {}, "pending": {}}
|
||||
payload[field] = value
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
monkeypatch.setattr(store, "_store_path", lambda: path)
|
||||
|
||||
assert store.is_approved("telegram", "123") is False
|
||||
assert store.list_pending() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", ["null", "[]", "true"])
|
||||
def test_load_treats_non_object_store_as_empty(tmp_path, monkeypatch, payload):
|
||||
path = tmp_path / "pairing.json"
|
||||
|
||||
@@ -181,7 +181,7 @@ def test_resolver_env_ref_missing_var_degrades_to_not_configured() -> None:
|
||||
|
||||
# Unresolved reference degrades to a falsy key rather than the literal
|
||||
# "${...}" string, so the config reports itself as not configured.
|
||||
assert not resolved.api_key
|
||||
assert resolved.api_key == ""
|
||||
assert resolved.configured is False
|
||||
|
||||
|
||||
|
||||
@@ -156,6 +156,28 @@ def test_parse_json_content_validates_user_role() -> None:
|
||||
_parse_json_content(body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("part", "field"),
|
||||
[
|
||||
({"type": "text", "text": 1}, r"content\[\]\.text"),
|
||||
(
|
||||
{"type": "image_url", "image_url": "not-an-object"},
|
||||
r"content\[\]\.image_url",
|
||||
),
|
||||
(
|
||||
{"type": "image_url", "image_url": {"url": 1}},
|
||||
r"image_url\.url",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_json_content_validates_typed_block_fields(part, field) -> None:
|
||||
"""Dynamic content blocks are checked before their values reach typed code."""
|
||||
body = {"messages": [{"role": "user", "content": [part]}]}
|
||||
|
||||
with pytest.raises(TypeError, match=field):
|
||||
_parse_json_content(body)
|
||||
|
||||
|
||||
def test_parse_json_content_rejects_oversized_base64_file(tmp_path) -> None:
|
||||
"""Oversized JSON data URLs should fail before writing to disk."""
|
||||
large_payload = base64.b64encode(b"x" * (11 * 1024 * 1024)).decode()
|
||||
|
||||
@@ -67,6 +67,13 @@ class TestExtractText:
|
||||
result = extract_text(txt_file)
|
||||
assert result == content
|
||||
|
||||
def test_extract_text_accepts_string_path(self, tmp_path: Path):
|
||||
"""String paths retain the compatibility behavior of Path inputs."""
|
||||
txt_file = tmp_path / "string-path.txt"
|
||||
txt_file.write_text("string path", encoding="utf-8")
|
||||
|
||||
assert extract_text(str(txt_file)) == "string path"
|
||||
|
||||
def test_extract_text_txt_file_with_truncation(self, tmp_path: Path):
|
||||
"""Test that large text files are truncated."""
|
||||
txt_file = tmp_path / "large.txt"
|
||||
|
||||
@@ -26,6 +26,14 @@ from nanobot.config.schema import MCPServerConfig
|
||||
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
||||
|
||||
|
||||
def test_type_checking_only_mcp_annotations_are_deferred() -> None:
|
||||
assert mcp_mod._MCPWrapperBase.__annotations__["_session"] == "ClientSession"
|
||||
assert MCPToolWrapper.__init__.__annotations__["session"] == "ClientSession"
|
||||
assert MCPResourceWrapper.__init__.__annotations__["resource_def"] == "Resource"
|
||||
assert MCPPromptWrapper.__init__.__annotations__["prompt_def"] == "Prompt"
|
||||
assert connect_mcp_servers.__annotations__["mcp_servers"] == "dict[str, MCPServerConfig]"
|
||||
|
||||
|
||||
class _FakeTextContent:
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
|
||||
Reference in New Issue
Block a user