refactor: enforce BasedPyright strict type checking (#5158)

This commit is contained in:
chengyongru
2026-07-29 21:37:11 +08:00
committed by GitHub
parent e703481755
commit 757ad9c764
166 changed files with 4728 additions and 2621 deletions
+135 -106
View File
@@ -1,15 +1,23 @@
"""CLI commands for nanobot."""
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false, reportUnusedFunction=false
import asyncio
import os
import select
import signal
import sys
import time
from collections.abc import Callable, Iterable
from collections.abc import Awaitable, Callable, Coroutine, Iterable
from contextlib import nullcontext, suppress
from pathlib import Path
from typing import Any
from types import FrameType
from typing import TYPE_CHECKING, Any, Literal, cast
if TYPE_CHECKING:
from nanobot.gateway.runtime import GatewayRuntime
from nanobot.providers.registry import ProviderSpec
# Force UTF-8 encoding for Windows console
if sys.platform == "win32":
@@ -17,8 +25,10 @@ if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
# Re-open stdout/stderr with UTF-8 encoding
with suppress(Exception):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if callable(reconfigure):
reconfigure(encoding="utf-8", errors="replace")
# Keep console encoding setup before importing CLI UI/logging libraries.
import typer # noqa: E402
@@ -52,6 +62,7 @@ from prompt_toolkit.application import run_in_terminal # noqa: E402
from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402
from prompt_toolkit.history import FileHistory # noqa: E402
from prompt_toolkit.key_binding import KeyBindings # noqa: E402
from prompt_toolkit.key_binding.key_processor import KeyPressEvent # noqa: E402
from prompt_toolkit.keys import Keys # noqa: E402
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
from pydantic import ValidationError # noqa: E402
@@ -139,7 +150,7 @@ def _ensure_interactive_tty_mode() -> None:
def _install_gateway_shutdown_handlers(
loop: asyncio.AbstractEventLoop,
shutdown_event: asyncio.Event,
tasks: list[asyncio.Task],
tasks: list[asyncio.Task[Any]],
print_status: Callable[[str], None],
) -> Callable[[], None]:
"""Install foreground gateway signal handlers and return a restore callback."""
@@ -298,8 +309,8 @@ def _pick_heartbeat_target_from_sessions(
# CLI input: prompt_toolkit for editing, paste, history, and display
# ---------------------------------------------------------------------------
_PROMPT_SESSION: PromptSession | None = None
_SAVED_TERM_ATTRS = None # original termios settings, restored on exit
_PROMPT_SESSION: PromptSession[str] | None = None
_saved_term_attrs: list[Any] | None = None # original termios settings, restored on exit
def _flush_pending_tty_input() -> None:
@@ -328,12 +339,12 @@ def _flush_pending_tty_input() -> None:
def _restore_terminal() -> None:
"""Restore terminal to its original state (echo, line buffering, etc.)."""
if _SAVED_TERM_ATTRS is None:
if _saved_term_attrs is None:
return
with suppress(Exception):
import termios
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved_term_attrs)
def _build_cli_key_bindings() -> KeyBindings:
@@ -357,20 +368,20 @@ def _build_cli_key_bindings() -> KeyBindings:
kb = KeyBindings()
@kb.add("enter")
def _(event):
def _(event: KeyPressEvent) -> None:
event.current_buffer.validate_and_handle()
@kb.add("escape", "enter") # Alt+Enter / Meta+Enter (ESC + CR, "\x1b\r")
def _(event):
def _(event: KeyPressEvent) -> None:
event.current_buffer.insert_text("\n")
# LF-as-Enter terminals send Alt+Enter as ESC + LF rather than ESC + CR.
@kb.add("escape", Keys.ControlJ) # Alt+Enter on LF-as-Enter terminals
def _(event):
def _(event: KeyPressEvent) -> None:
event.current_buffer.insert_text("\n")
@kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals
def _(event):
def _(event: KeyPressEvent) -> None:
event.current_buffer.insert_text("\n")
return kb
@@ -378,13 +389,13 @@ def _build_cli_key_bindings() -> KeyBindings:
def _init_prompt_session() -> None:
"""Create the prompt_toolkit session with persistent file history."""
global _PROMPT_SESSION, _SAVED_TERM_ATTRS
global _PROMPT_SESSION, _saved_term_attrs
# Save terminal state so we can restore it on exit
with suppress(Exception):
import termios
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
_saved_term_attrs = termios.tcgetattr(sys.stdin.fileno())
from nanobot.config.paths import get_cli_history_path
@@ -405,11 +416,14 @@ def _make_console() -> Console:
return Console(file=sys.stdout)
def _render_interactive_ansi(render_fn) -> str:
def _render_interactive_ansi(render_fn: Callable[[Console], None]) -> str:
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
ansi_console = Console(
force_terminal=sys.stdout.isatty(),
color_system=console.color_system or "standard",
color_system=cast(
Literal["auto", "standard", "256", "truecolor", "windows"],
console.color_system or "standard",
),
width=console.width,
)
with ansi_console.capture() as capture:
@@ -420,7 +434,7 @@ def _render_interactive_ansi(render_fn) -> str:
def _print_agent_response(
response: str,
render_markdown: bool,
metadata: dict | None = None,
metadata: dict[str, Any] | None = None,
show_header: bool = True,
) -> None:
"""Render assistant response with consistent terminal styling."""
@@ -434,7 +448,9 @@ def _print_agent_response(
console.print()
def _response_renderable(content: str, render_markdown: bool, metadata: dict | None = None):
def _response_renderable(
content: str, render_markdown: bool, metadata: dict[str, Any] | None = None
) -> Text | Markdown:
"""Render plain-text command output without markdown collapsing newlines."""
if not render_markdown:
return Text(content)
@@ -457,19 +473,19 @@ async def _print_interactive_line(text: str) -> None:
async def _print_interactive_response(
response: str,
render_markdown: bool,
metadata: dict | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
"""Print async interactive replies with prompt_toolkit-safe Rich styling."""
def _write() -> None:
content = response or ""
ansi = _render_interactive_ansi(
lambda c: (
c.print(),
c.print(f"[cyan]{__logo__} nanobot[/cyan]"),
c.print(_response_renderable(content, render_markdown, metadata)),
c.print(),
)
)
def _render(target: Console) -> None:
target.print()
target.print(f"[cyan]{__logo__} nanobot[/cyan]")
target.print(_response_renderable(content, render_markdown, metadata))
target.print()
ansi = _render_interactive_ansi(_render)
print_formatted_text(ANSI(ansi), end="")
await run_in_terminal(_write)
@@ -663,10 +679,11 @@ def onboard(
loaded.agents.defaults.workspace = workspace
return loaded
loaded_config: Config | None = None
# Create or update config
if config_path.exists():
if wizard:
config = _apply_workspace_override(load_config(config_path))
loaded_config = _apply_workspace_override(load_config(config_path))
else:
should_refresh = non_interactive_refresh
if not non_interactive_refresh:
@@ -678,37 +695,39 @@ def onboard(
" [bold]N[/bold] = refresh config, keeping existing values and adding new fields"
)
if typer.confirm("Overwrite?"):
config = _apply_workspace_override(Config())
save_config(config, config_path)
loaded_config = _apply_workspace_override(Config())
save_config(loaded_config, config_path)
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
else:
should_refresh = True
if should_refresh:
config = _apply_workspace_override(load_config(config_path))
save_config(config, config_path)
loaded_config = _apply_workspace_override(load_config(config_path))
save_config(loaded_config, config_path)
console.print(
f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)"
)
else:
config = _apply_workspace_override(Config())
loaded_config = _apply_workspace_override(Config())
# In wizard mode, don't save yet - the wizard will handle saving if should_save=True
if not wizard:
save_config(config, config_path)
save_config(loaded_config, config_path)
console.print(f"[green]✓[/green] Created config at {config_path}")
assert loaded_config is not None
# Run interactive wizard if enabled
if wizard:
from nanobot.cli.onboard import run_onboard
try:
result = run_onboard(initial_config=config)
result = run_onboard(initial_config=loaded_config)
if not result.should_save:
console.print("[yellow]Configuration discarded. No changes were saved.[/yellow]")
return
config = result.config
save_config(config, config_path)
loaded_config = result.config
save_config(loaded_config, config_path)
console.print(f"[green]✓[/green] Config saved at {config_path}")
except Exception as e:
console.print(f"[red]✗[/red] Error during configuration: {e}")
@@ -717,7 +736,7 @@ def onboard(
_onboard_plugins(config_path)
# Create workspace, preferring the configured workspace path.
workspace_path = get_workspace_path(config.workspace_path)
workspace_path = get_workspace_path(loaded_config.workspace_path)
if not workspace_path.exists():
workspace_path.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓[/green] Created workspace at {workspace_path}")
@@ -1000,7 +1019,7 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
"""Return the current WebSocket config as a mutable alias-key dictionary."""
from nanobot.channels.websocket.runtime import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
current: Any = getattr(config.channels, "websocket", None) or {}
model = WebSocketConfig.model_validate(current)
return model.model_dump(by_alias=True, exclude_none=True)
@@ -1008,7 +1027,7 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
def _webui_channel_enabled(config: Config) -> bool:
from nanobot.channels.websocket.runtime import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
current: Any = getattr(config.channels, "websocket", None) or {}
return bool(WebSocketConfig.model_validate(current).enabled)
@@ -1167,7 +1186,7 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool)
"""Enable the local WebUI channel with safe localhost defaults."""
from nanobot.channels.websocket.runtime import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
current: Any = getattr(config.channels, "websocket", None) or {}
model = WebSocketConfig.model_validate(current)
changed = False
generated_secret = False
@@ -1329,7 +1348,7 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
def _attach_to_background_gateway(runtime: Any) -> None:
def _attach_to_background_gateway(runtime: "GatewayRuntime") -> None:
"""Keep a foreground WebUI command attached to a managed gateway."""
_print_webui_foreground_lifecycle(attached=True)
try:
@@ -1512,16 +1531,19 @@ def serve(
api_key=api_key,
)
async def on_startup(_app):
async def on_startup(_app: Any) -> None:
await agent_loop._connect_mcp()
async def on_cleanup(_app):
async def on_cleanup(_app: Any) -> None:
await agent_loop.close_mcp()
api_app.on_startup.append(on_startup)
api_app.on_cleanup.append(on_cleanup)
web.run_app(api_app, host=host, port=port, print=lambda msg: logger.info(msg))
def _log_aiohttp(message: object) -> None:
logger.info("{}", message)
web.run_app(api_app, host=host, port=port, print=_log_aiohttp)
# ============================================================================
@@ -1778,6 +1800,7 @@ def _run_gateway(
from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob
from nanobot.providers.factory import (
ProviderSnapshot,
build_provider_snapshot,
build_unconfigured_provider_snapshot,
load_provider_snapshot,
@@ -1823,12 +1846,15 @@ def _run_gateway(
runtime_events = RuntimeEventBus()
fallback_model_observer = build_webui_fallback_model_observer(bus)
def _observe_fallback_models(snapshot):
def _observe_fallback_models(snapshot: ProviderSnapshot) -> ProviderSnapshot:
if isinstance(snapshot.provider, FallbackProvider):
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
return snapshot
def _load_gateway_provider_snapshot(*args: Any, **kwargs: Any):
def _load_gateway_provider_snapshot(
*args: Any,
**kwargs: Any,
) -> ProviderSnapshot:
try:
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
except ValueError as exc:
@@ -1896,10 +1922,13 @@ def _run_gateway(
local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook],
)
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
agent._schedule_background(cast(Coroutine[Any, Any, None], awaitable))
webui_turn_coordinator = WebuiTurnCoordinator(
bus=bus,
sessions=session_manager,
schedule_background=lambda coro: agent._schedule_background(coro),
schedule_background=_schedule_webui_background,
)
webui_turn_coordinator.subscribe(runtime_events)
from nanobot.bus.events import OutboundMessage
@@ -1944,14 +1973,14 @@ def _run_gateway(
session_manager.save(session)
await bus.publish_outbound(msg)
message_tool = getattr(agent, "tools", {}).get("message")
message_tool = agent.tools.get("message")
if isinstance(message_tool, MessageTool):
message_tool.set_send_callback(_deliver_to_channel)
# Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent."""
async def _silent(*_args, **_kwargs):
async def _silent(*_args: Any, **_kwargs: Any) -> None:
pass
# Dream is an internal job — run directly, not through the agent loop.
@@ -1972,10 +2001,7 @@ def _run_gateway(
return None
prompt, last_cursor = result
key = dream_session_key()
resolve_dream_runtime = getattr(agent, "dream_runtime", None)
dream_runtime = (
resolve_dream_runtime() if callable(resolve_dream_runtime) else None
)
dream_runtime = agent.dream_runtime()
resp = await agent.process_direct(
prompt,
session_key=key,
@@ -2111,11 +2137,7 @@ def _run_gateway(
cron.on_job = on_cron_job
def _webui_runtime_model_name() -> str | None:
model = getattr(agent, "model", None)
if isinstance(model, str):
stripped = model.strip()
return stripped or None
return None
return agent.model.strip() or None
# Create channel manager (forwards SessionManager so the WebSocket channel
# can serve the embedded webui's REST surface).
@@ -2126,12 +2148,8 @@ def _run_gateway(
cron_service=cron,
local_trigger_store=trigger_store,
webui_runtime_model_name=_webui_runtime_model_name,
webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None),
webui_local_trigger_pending_ids=getattr(
agent,
"pending_local_trigger_ids_for_session",
None,
),
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
@@ -2158,8 +2176,9 @@ def _run_gateway(
console.print("[yellow]Warning: No channels enabled[/yellow]")
cron_status = cron.status()
if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
cron_job_count = cast(int, cron_status["jobs"])
if cron_job_count > 0:
console.print(f"[green]✓[/green] Cron: {cron_job_count} scheduled jobs")
hb_cfg = config.gateway.heartbeat
if hb_cfg.enabled:
@@ -2167,13 +2186,16 @@ def _run_gateway(
else:
console.print("[yellow]✗[/yellow] Heartbeat: disabled")
async def _health_server(host: str, health_port: int):
async def _health_server(host: str, health_port: int) -> None:
"""Lightweight HTTP health endpoint on the gateway port."""
import json as _json
connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS)
async def handle(reader, writer):
async def handle(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
if connection_slots.locked():
writer.close()
return
@@ -2260,7 +2282,7 @@ def _run_gateway(
# Channels start asynchronously; a short poll lets us avoid racing the bind.
for _ in range(40): # ~4s max
try:
reader, writer = await asyncio.open_connection(
_reader, writer = await asyncio.open_connection(
target_host,
target_port,
)
@@ -2276,10 +2298,10 @@ def _run_gateway(
except Exception as e:
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
async def run():
tasks: list[asyncio.Task] = []
shutdown_task: asyncio.Task | None = None
runtime_tasks: asyncio.Future | None = None
async def run() -> None:
tasks: list[asyncio.Task[Any]] = []
shutdown_task: asyncio.Task[Any] | None = None
runtime_tasks: asyncio.Future[list[Any]] | None = None
runtime_tasks_drained = False
shutdown_event = asyncio.Event()
_ensure_interactive_tty_mode()
@@ -2306,7 +2328,7 @@ def _run_gateway(
asyncio.create_task(
run_local_trigger_queue(
store=trigger_store,
submit_turn=getattr(agent, "submit_local_trigger_turn", None),
submit_turn=agent.submit_local_trigger_turn,
is_channel_enabled=lambda name: channels.get_channel(name) is not None,
),
name="nanobot-local-triggers",
@@ -2334,7 +2356,7 @@ def _run_gateway(
if runtime_tasks in done:
runtime_tasks_drained = True
await runtime_tasks
elif runtime_tasks is not None:
else:
runtime_tasks.cancel()
except KeyboardInterrupt:
console.print("\nShutting down...")
@@ -2410,33 +2432,33 @@ def agent(
from nanobot.providers.factory import make_provider
from nanobot.providers.image_generation import image_gen_provider_configs
config = _load_runtime_config(config, workspace)
runtime_config = _load_runtime_config(config, workspace)
try:
provider = make_provider(config)
provider = make_provider(runtime_config)
except ValueError as exc:
_print_agent_start_error(exc)
raise typer.Exit(1) from exc
sync_workspace_templates(config.workspace_path)
sync_workspace_templates(runtime_config.workspace_path)
bus = MessageBus()
# Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(config.workspace_path):
_migrate_cron_store(config)
if is_default_workspace(runtime_config.workspace_path):
_migrate_cron_store(runtime_config)
# Create cron service with workspace-scoped store
cron_store_path = config.workspace_path / "cron" / "jobs.json"
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path)
_set_nanobot_logs(logs)
try:
agent_loop = AgentLoop.from_config(
config, bus,
runtime_config, bus,
provider=provider,
cron_service=cron,
image_generation_provider_configs=image_gen_provider_configs(config),
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
hook_factories=[create_file_edit_activity_hook],
)
except ValueError as exc:
@@ -2452,7 +2474,9 @@ def agent(
# Shared reference for progress callbacks
_thinking: ThinkingSpinner | None = None
def _make_progress(renderer: StreamRenderer | None = None):
def _make_progress(
renderer: StreamRenderer | None = None,
) -> Callable[..., Awaitable[None]]:
reasoning_buffer = _ReasoningBuffer()
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
@@ -2482,11 +2506,11 @@ def agent(
if message:
# Single message mode — direct call, no bus needed
async def run_once():
async def run_once() -> None:
renderer = StreamRenderer(
render_markdown=markdown,
bot_name=config.agents.defaults.bot_name,
bot_icon=config.agents.defaults.bot_icon,
bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon,
)
response = await agent_loop.process_direct(
message, session_id,
@@ -2512,8 +2536,8 @@ def agent(
# Interactive mode — route through bus like other channels
from nanobot.bus.events import InboundMessage
_init_prompt_session()
_model, _preset_tag = _model_display(config)
_icon = config.agents.defaults.bot_icon or __logo__
_model, _preset_tag = _model_display(runtime_config)
_icon = runtime_config.agents.defaults.bot_icon or __logo__
console.print(f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
if ":" in session_id:
@@ -2521,7 +2545,7 @@ def agent(
else:
cli_channel, cli_chat_id = "cli", session_id
def _handle_signal(signum, frame):
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
sig_name = signal.Signals(signum).name
_restore_terminal()
console.print(f"\nReceived {sig_name}, goodbye!")
@@ -2537,7 +2561,7 @@ def agent(
if hasattr(signal, 'SIGPIPE'):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
async def run_interactive():
async def run_interactive() -> None:
bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event()
turn_done.set()
@@ -2545,7 +2569,7 @@ def agent(
renderer: StreamRenderer | None = None
reasoning_buffer = _ReasoningBuffer()
async def _consume_outbound():
async def _consume_outbound() -> None:
while True:
try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
@@ -2578,7 +2602,7 @@ def agent(
if await _maybe_print_interactive_progress(
msg,
renderer,
None,
agent_loop.channels_config,
renderer,
reasoning_buffer,
@@ -2625,8 +2649,8 @@ def agent(
reasoning_buffer.clear()
renderer = StreamRenderer(
render_markdown=markdown,
bot_name=config.agents.defaults.bot_name,
bot_icon=config.agents.defaults.bot_icon,
bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon,
)
await bus.publish_inbound(InboundMessage(
@@ -2701,7 +2725,7 @@ def channels_status(
if section is None:
enabled = False
elif isinstance(section, dict):
enabled = section.get("enabled", False)
enabled = cast(dict[str, Any], section).get("enabled", False)
else:
enabled = getattr(section, "enabled", False)
table.add_row(
@@ -2719,10 +2743,11 @@ def channels_login(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Authenticate with a channel via QR code or other interactive login."""
from nanobot.bus.queue import MessageBus
from nanobot.channels.registry import discover_all
_, loaded = _load_inspection_config(config=config)
channel_cfg = getattr(loaded.channels, channel_name, None) or {}
channel_cfg: Any = getattr(loaded.channels, channel_name, None) or {}
# Validate channel exists
all_channels = discover_all()
@@ -2733,8 +2758,8 @@ def channels_login(
console.print(f"{__logo__} {all_channels[channel_name].display_name} Login\n")
channel_cls = all_channels[channel_name]
channel = channel_cls(channel_cfg, bus=None)
channel_factory = all_channels[channel_name]
channel = channel_factory(channel_cfg, bus=MessageBus())
success = asyncio.run(channel.login(force=force))
@@ -2923,24 +2948,28 @@ _OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
}
def _register_login(name: str):
def _register_login(
name: str,
) -> Callable[[Callable[[], None]], Callable[[], None]]:
"""Register an OAuth login handler."""
def decorator(fn):
def decorator(fn: Callable[[], None]) -> Callable[[], None]:
_LOGIN_HANDLERS[name] = fn
return fn
return decorator
def _register_logout(name: str):
def _register_logout(
name: str,
) -> Callable[[Callable[[], None]], Callable[[], None]]:
"""Register an OAuth logout handler."""
def decorator(fn):
def decorator(fn: Callable[[], None]) -> Callable[[], None]:
_LOGOUT_HANDLERS[name] = fn
return fn
return decorator
def _resolve_oauth_provider(provider: str):
def _resolve_oauth_provider(provider: str) -> "ProviderSpec":
"""Resolve and validate an OAuth provider configuration."""
from nanobot.providers.registry import PROVIDERS
+2
View File
@@ -1,5 +1,7 @@
"""Typer commands for foreground and background gateway control."""
# pyright: reportUnusedFunction=false
from __future__ import annotations
import subprocess
+116 -69
View File
@@ -1,19 +1,27 @@
"""Interactive onboarding questionnaire for nanobot."""
# pyright: reportMissingTypeStubs=false, reportUnusedFunction=false
import asyncio
import json
import types
from collections.abc import Callable, Iterable, Sized
from contextlib import suppress
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Literal, NamedTuple, get_args, get_origin
from typing import Any, Literal, NamedTuple, TypeVar, cast, get_args, get_origin
try:
import questionary
except ModuleNotFoundError: # pragma: no cover - exercised in environments without wizard deps
questionary = None
from loguru import logger
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from pydantic import BaseModel
from pydantic.fields import FieldInfo
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
@@ -29,6 +37,8 @@ from nanobot.config.schema import Config, ModelPresetConfig
console = Console()
_ModelT = TypeVar("_ModelT", bound=BaseModel)
@dataclass
class OnboardResult:
@@ -119,14 +129,14 @@ _CHANNEL_LOGIN_CHOICE = "Login with QR/link"
_CHANNEL_ADVANCED_CHOICE = "Edit advanced settings"
def _get_questionary():
def _get_questionary() -> Any:
"""Return questionary or raise a clear error when wizard deps are unavailable."""
if questionary is None:
raise RuntimeError(
"Interactive onboarding requires the optional 'questionary' dependency. "
"Install project dependencies and rerun with --wizard."
)
return questionary
return cast(Any, questionary)
def _select_with_back(
@@ -147,7 +157,6 @@ def _select_with_back(
import shutil
from prompt_toolkit.application import Application
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import Layout
from prompt_toolkit.layout.containers import HSplit, Window
@@ -170,8 +179,8 @@ def _select_with_back(
visible_count = min(len(choices), max(1, terminal_lines - 3))
# Build menu items (uses closure over selected_index)
def get_menu_text():
items = []
def get_menu_text() -> list[tuple[str, str]]:
items: list[tuple[str, str]] = []
start, end = _choice_viewport(selected_index, len(choices), visible_count)
for i in range(start, end):
choice = choices[i]
@@ -182,14 +191,14 @@ def _select_with_back(
return items
# Create layout
menu_control = FormattedTextControl(get_menu_text, show_cursor=False)
menu_control = FormattedTextControl(cast(Any, get_menu_text), show_cursor=False)
menu_window = Window(content=menu_control, height=visible_count, always_hide_cursor=True)
def get_prompt_text():
def get_prompt_text() -> list[tuple[str, str]]:
suffix = f" ({selected_index + 1}/{len(choices)})" if len(choices) > visible_count else ""
return [("class:question", f"{prompt}{suffix}")]
prompt_control = FormattedTextControl(get_prompt_text, show_cursor=False)
prompt_control = FormattedTextControl(cast(Any, get_prompt_text), show_cursor=False)
prompt_window = Window(content=prompt_control, height=1, always_hide_cursor=True)
layout = Layout(HSplit([prompt_window, menu_window]))
@@ -198,34 +207,34 @@ def _select_with_back(
bindings = KeyBindings()
@bindings.add(Keys.Up)
def _up(event):
def _up(event: KeyPressEvent) -> None:
nonlocal selected_index
selected_index = (selected_index - 1) % len(choices)
event.app.invalidate()
@bindings.add(Keys.Down)
def _down(event):
def _down(event: KeyPressEvent) -> None:
nonlocal selected_index
selected_index = (selected_index + 1) % len(choices)
event.app.invalidate()
@bindings.add(Keys.Enter)
def _enter(event):
def _enter(event: KeyPressEvent) -> None:
state["result"] = choices[selected_index]
event.app.exit()
@bindings.add("escape")
def _escape(event):
def _escape(event: KeyPressEvent) -> None:
state["result"] = _BACK_PRESSED
event.app.exit()
@bindings.add(Keys.Left)
def _left(event):
def _left(event: KeyPressEvent) -> None:
state["result"] = _BACK_PRESSED
event.app.exit()
@bindings.add(Keys.ControlC)
def _ctrl_c(event):
def _ctrl_c(event: KeyPressEvent) -> None:
state["result"] = None
event.app.exit()
@@ -235,7 +244,7 @@ def _select_with_back(
"question": f"fg:{_UI_TEXT}",
})
app = Application(layout=layout, key_bindings=bindings, style=style)
app = Application[object](layout=layout, key_bindings=bindings, style=style)
app.ttimeoutlen = 0.05
app.timeoutlen = 0.05
try:
@@ -268,7 +277,7 @@ class FieldTypeInfo(NamedTuple):
inner_type: Any
def _get_field_type_info(field_info) -> FieldTypeInfo:
def _get_field_type_info(field_info: FieldInfo) -> FieldTypeInfo:
"""Extract field type info from Pydantic field."""
annotation = field_info.annotation
if annotation is None:
@@ -285,10 +294,11 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
args = get_args(annotation)
_simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"}
origin_name = getattr(origin, "__name__", None)
if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"):
if origin is list or origin_name == "List":
return FieldTypeInfo("list", args[0] if args else str)
if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"):
if origin is dict or origin_name == "Dict":
return FieldTypeInfo("dict", None)
for py_type, name in _simple_types.items():
if annotation is py_type:
@@ -300,7 +310,7 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
return FieldTypeInfo("str", None)
def _get_field_display_name(field_key: str, field_info) -> str:
def _get_field_display_name(field_key: str, field_info: FieldInfo | None) -> str:
"""Get display name for a field."""
if field_info and field_info.description:
return field_info.description
@@ -349,22 +359,30 @@ def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
masked = _mask_value(value)
return f"[dim]{masked}[/dim]" if rich else masked
if isinstance(value, BaseModel):
parts = []
model_parts: list[str] = []
for fname, _finfo in type(value).model_fields.items():
fval = getattr(value, fname, None)
formatted = _format_value(fval, rich=False, field_name=fname)
if formatted != "[not set]":
parts.append(f"{fname}={formatted}")
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
model_parts.append(f"{fname}={formatted}")
return (
", ".join(model_parts)
if model_parts
else ("[dim]not set[/dim]" if rich else "[not set]")
)
if isinstance(value, list):
return ", ".join(str(v) for v in value)
return ", ".join(str(v) for v in cast(list[Any], value))
if isinstance(value, dict):
# Handle dicts containing BaseModel instances
parts = []
for k, v in value.items():
mapping_parts: list[str] = []
for k, v in cast(dict[Any, Any], value).items():
formatted = _format_value(v, rich=False, field_name=str(k))
parts.append(f"{k}: {formatted}")
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
mapping_parts.append(f"{k}: {formatted}")
return (
", ".join(mapping_parts)
if mapping_parts
else ("[dim]not set[/dim]" if rich else "[not set]")
)
return str(value)
@@ -373,13 +391,13 @@ def _format_value_for_input(value: Any, field_type: str) -> str:
if value is None or value == "":
return ""
if field_type == "list" and isinstance(value, list):
return ",".join(str(v) for v in value)
return ",".join(str(v) for v in cast(list[Any], value))
if field_type == "dict" and isinstance(value, dict):
return json.dumps(value)
return str(value)
def _validate_field_constraint(value: Any, field_info) -> str | None:
def _validate_field_constraint(value: Any, field_info: FieldInfo | None) -> str | None:
"""Validate a value against Pydantic Field constraints.
Returns an error message string if validation fails, None if valid.
@@ -388,7 +406,8 @@ def _validate_field_constraint(value: Any, field_info) -> str | None:
if field_info is None or not hasattr(field_info, "metadata"):
return None
for m in field_info.metadata:
for metadata in field_info.metadata:
m = metadata
if hasattr(m, "ge") and isinstance(value, (int, float)):
if value < m.ge:
return f"Value must be >= {m.ge}"
@@ -402,16 +421,16 @@ def _validate_field_constraint(value: Any, field_info) -> str | None:
if value >= m.lt:
return f"Value must be < {m.lt}"
if hasattr(m, "min_length") and hasattr(value, "__len__"):
if len(value) < m.min_length:
if len(cast(Sized, value)) < m.min_length:
return f"Length must be >= {m.min_length}"
if hasattr(m, "max_length") and hasattr(value, "__len__"):
if len(value) > m.max_length:
if len(cast(Sized, value)) > m.max_length:
return f"Length must be <= {m.max_length}"
return None
def _get_constraint_hint(field_info) -> str:
def _get_constraint_hint(field_info: FieldInfo | None) -> str:
"""Derive a human-readable constraint hint from field metadata.
Returns a string like " - 0-10" or " - >= 0" to append to field display names.
@@ -421,7 +440,8 @@ def _get_constraint_hint(field_info) -> str:
ge_val = None
le_val = None
for m in field_info.metadata:
for metadata in field_info.metadata:
m = metadata
if hasattr(m, "ge"):
ge_val = m.ge
if hasattr(m, "le"):
@@ -439,7 +459,11 @@ def _get_constraint_hint(field_info) -> str:
# --- Rich UI Components ---
def _show_config_panel(display_name: str, model: BaseModel, fields: list) -> None:
def _show_config_panel(
display_name: str,
model: BaseModel,
fields: list[tuple[str, FieldInfo]],
) -> None:
"""Display current configuration as a rich table."""
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Field", style=_UI_ACCENT)
@@ -504,20 +528,18 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None:
).ask()
def _input_back_key_bindings():
def _input_back_key_bindings() -> KeyBindings:
"""Return key bindings that make Escape behave like a local back action."""
from prompt_toolkit.key_binding import KeyBindings
bindings = KeyBindings()
@bindings.add("escape")
def _escape(event):
def _escape(event: KeyPressEvent) -> None:
event.app.exit(result=_BACK_PRESSED)
return bindings
def _ask_prompt(prompt):
def _ask_prompt(prompt: Any) -> Any:
"""Ask a questionary prompt with responsive Escape handling."""
app = getattr(prompt, "application", None)
if app is not None:
@@ -528,7 +550,12 @@ def _ask_prompt(prompt):
return prompt.ask()
def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any:
def _input_text(
display_name: str,
current: Any,
field_type: str,
field_info: FieldInfo | None = None,
) -> Any:
"""Get text input and parse based on field type."""
default = _format_value_for_input(current, field_type)
@@ -591,7 +618,10 @@ def _input_secret(display_name: str) -> str | None | object:
def _input_with_existing(
display_name: str, current: Any, field_type: str, field_info=None
display_name: str,
current: Any,
field_type: str,
field_info: FieldInfo | None = None,
) -> Any:
"""Handle input with 'keep existing' option for non-empty values."""
has_existing = current is not None and current != "" and current != {} and current != []
@@ -624,8 +654,6 @@ def _input_model_with_autocomplete(
"""Get model input with autocomplete suggestions.
"""
from prompt_toolkit.completion import Completer, Completion
default = str(current) if current else ""
class DynamicModelCompleter(Completer):
@@ -634,7 +662,12 @@ def _input_model_with_autocomplete(
def __init__(self, provider_name: str):
self.provider = provider_name
def get_completions(self, document, _complete_event):
def get_completions(
self,
document: Document,
complete_event: CompleteEvent,
) -> Iterable[Completion]:
_ = complete_event
text = document.text_before_cursor
suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
for model in suggestions:
@@ -735,7 +768,7 @@ def _handle_model_field(
return
if new_value is not None and new_value != current_value:
setattr(working_model, field_name, new_value)
_try_auto_fill_context_window(working_model, new_value)
_try_auto_fill_context_window(working_model, cast(str, new_value))
def _handle_context_window_field(
@@ -794,7 +827,11 @@ def _handle_fallback_models_field(
"""Handle the 'fallback_models' field with preset-aware list management."""
from nanobot.config.schema import InlineFallbackConfig
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
items: list[Any] = (
list(cast(list[Any], current_value))
if isinstance(current_value, list)
else []
)
preset_names = sorted(_MODEL_PRESET_CACHE)
while True:
@@ -888,11 +925,11 @@ def _is_str_or_none(annotation: Any) -> bool:
def _configure_pydantic_model(
model: BaseModel,
model: _ModelT,
display_name: str,
*,
skip_fields: set[str] | None = None,
) -> BaseModel | None:
) -> _ModelT | None:
"""Configure a Pydantic model interactively.
Returns the updated model when the user selects "Done" or navigates back.
@@ -901,7 +938,7 @@ def _configure_pydantic_model(
skip_fields = skip_fields or set()
working_model = model.model_copy(deep=True)
fields = [
fields: list[tuple[str, FieldInfo]] = [
(name, info)
for name, info in type(working_model).model_fields.items()
if name not in skip_fields
@@ -911,7 +948,7 @@ def _configure_pydantic_model(
return working_model
def get_choices() -> list[str]:
items = []
items: list[str] = []
for fname, finfo in fields:
value = getattr(working_model, fname, None)
display = _get_field_display_name(fname, finfo)
@@ -1057,6 +1094,10 @@ def _sync_preset_cache(config: Config) -> None:
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
def _validate_nonempty_name(text: str) -> bool | str:
return True if text and text.strip() else "Name cannot be empty"
def _configure_model_presets(config: Config) -> None:
"""Configure model presets (CRUD)."""
_sync_preset_cache(config)
@@ -1099,7 +1140,7 @@ def _configure_model_presets(config: Config) -> None:
if answer == "[+] Add new preset":
name_input = _get_questionary().text(
"Preset name:",
validate=lambda t: True if t and t.strip() else "Name cannot be empty",
validate=_validate_nonempty_name,
).ask()
if not name_input:
continue
@@ -1218,7 +1259,7 @@ def _configure_providers(config: Config) -> None:
def get_provider_choices() -> list[str]:
"""Build provider choices with config status indicators."""
choices = []
choices: list[str] = []
for name, display in _get_provider_names().items():
provider = getattr(config.providers, name, None)
if provider and provider.api_key:
@@ -1427,7 +1468,7 @@ _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
}
_SETTINGS_GETTER = {
_SETTINGS_GETTER: dict[str, Callable[[Config], BaseModel]] = {
"Agent Settings": lambda c: c.agents.defaults,
"Channel Common": lambda c: c.channels,
"API Server": lambda c: c.api,
@@ -1435,7 +1476,7 @@ _SETTINGS_GETTER = {
"Tools": lambda c: c.tools,
}
_SETTINGS_SETTER = {
_SETTINGS_SETTER: dict[str, Callable[[Config, BaseModel], None]] = {
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
"Channel Common": lambda c, v: setattr(c, "channels", v),
"API Server": lambda c, v: setattr(c, "api", v),
@@ -1449,7 +1490,7 @@ def _configure_general_settings(config: Config, section: str) -> None:
meta = _SETTINGS_SECTIONS.get(section)
if not meta:
return
display_name, subtitle, skip = meta
display_name, _subtitle, skip = meta
model = _SETTINGS_GETTER[section](config)
updated = _configure_pydantic_model(model, display_name, skip_fields=skip)
if updated is not None:
@@ -1495,7 +1536,7 @@ def _show_summary(config: Config) -> None:
console.print()
# Providers
provider_rows = []
provider_rows: list[tuple[str, str]] = []
for name, display in _get_provider_names().items():
provider = getattr(config.providers, name, None)
status = (
@@ -1507,12 +1548,12 @@ def _show_summary(config: Config) -> None:
_print_summary_panel(provider_rows, "LLM Providers")
# Channels
channel_rows = []
channel_rows: list[tuple[str, str]] = []
for name, display in _get_channel_names().items():
channel = getattr(config.channels, name, None)
if channel:
enabled = (
channel.get("enabled", False)
cast(dict[str, Any], channel).get("enabled", False)
if isinstance(channel, dict)
else getattr(channel, "enabled", False)
)
@@ -1523,7 +1564,7 @@ def _show_summary(config: Config) -> None:
_print_summary_panel(channel_rows, "Chat Channels")
# Model Presets
preset_rows = []
preset_rows: list[tuple[str, str]] = []
for name, preset in config.model_presets.items():
preset_rows.append((name, f"{preset.model} - ctx {preset.context_window_tokens}"))
_print_summary_panel(preset_rows, "Model Presets")
@@ -1562,7 +1603,7 @@ def _set_primary_quick_start_preset(config: Config, provider_name: str, model: s
def _show_quick_start_progress(active_step: int) -> None:
"""Render a compact step tracker for Quick Start."""
parts = []
parts: list[str] = []
for idx, label in enumerate(_QUICK_START_STEPS, 1):
if idx < active_step:
parts.append(f"[{_UI_SUCCESS}]{idx}. {label}[/]")
@@ -1755,7 +1796,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
continue
if api_base_result is None:
return False
api_base, base_was_prompted = api_base_result
api_base, base_was_prompted = cast(
tuple[str, bool],
api_base_result,
)
api_key: str | None = None
if _quick_start_requires_api_key(provider_name, provider_info):
@@ -1778,7 +1822,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
continue
if api_base_result is None:
return False
api_base, base_was_prompted = api_base_result
api_base, base_was_prompted = cast(
tuple[str, bool],
api_base_result,
)
provider_config = getattr(config.providers, provider_name, None)
if provider_config is None:
@@ -1792,7 +1839,7 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
)
if model is _BACK_PRESSED:
continue
model = (model or "").strip()
model = cast(str, model or "").strip()
if not model:
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
return False
@@ -1850,7 +1897,7 @@ def _enable_quick_start_websocket_defaults(config: Config) -> bool:
console.print("[red]No configuration class found for websocket[/red]")
return False
current = getattr(config.channels, "websocket", None) or {}
current: Any = getattr(config.channels, "websocket", None) or {}
model = config_cls.model_validate(current)
if hasattr(model, "enabled"):
setattr(model, "enabled", True)
@@ -1997,7 +2044,7 @@ def _configure_advanced_settings(config: Config) -> None:
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
break
_advanced_dispatch = {
_advanced_dispatch: dict[str, Callable[[], None]] = {
"[P] LLM Provider": lambda: _configure_providers(config),
"[M] Model Presets": lambda: _configure_model_presets(config),
"[C] Chat Channel": lambda: _configure_channels(config),
@@ -2008,9 +2055,9 @@ def _configure_advanced_settings(config: Config) -> None:
"[T] Tools": lambda: _configure_general_settings(config, "Tools"),
"[V] View Configuration Summary": lambda: _show_summary(config),
}
action_fn = _advanced_dispatch.get(answer)
action_fn = _advanced_dispatch.get(cast(str, answer))
if action_fn:
last_choice = answer
last_choice = cast(str, answer)
action_fn()
+4 -3
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import sys
from contextlib import contextmanager, nullcontext
from typing import Literal
from rich.console import Console
from rich.live import Live
@@ -51,12 +52,12 @@ class ThinkingSpinner:
self._spinner = c.status(f"[dim]{bot_name} is thinking...[/dim]", spinner="dots")
self._active = False
def __enter__(self):
def __enter__(self) -> ThinkingSpinner:
self._spinner.start()
self._active = True
return self
def __exit__(self, *exc):
def __exit__(self, *exc: object) -> Literal[False]:
self._active = False
self._spinner.stop()
_clear_current_line(self._console)
@@ -110,7 +111,7 @@ class StreamRenderer:
self._header_printed = False
self._start_spinner()
def _renderable(self):
def _renderable(self) -> Markdown | Text:
"""Create a renderable from the current buffer."""
if self._md and self._buf:
return Markdown(self._buf)