chore: 引入 pyright 类型检查清零 + README 工程化章节

pyright(basic 模式)从 15 errors / 10 warnings 降到 0/0:

- pyproject: pythonPlatform="All"(termios/msvcrt 双平台分支不再误报),
  reportUnsupportedDunderAll=false(__init__ 惰性导出属正常写法)
- keys.py: 捕获 sys.stdin 局部变量 + 提前返回,消除 fileno() 的 None 收窄告警
- server/app.py: lifespan 改用 @asynccontextmanager 并标注 AsyncIterator[None]
- server/actions.py: 显式标注 result: dict[str, Any],消除 isinstance 冗余报错
- server/__main__.py: 用 importlib.util.find_spec 探测 websockets
- tests/test_server.py: 同上,uvicorn 探测改为 find_spec
- tools/preview_tui.py: TYPE_CHECKING 下引入 RemoteFarm,修正返回类型标注
- farm/tui.py: 取消未使用变量,统一 round/取整写法

其他:
- README: 新增第 11 节「工程化:uv + ruff + pyright」,快速开始全部改用
  uv sync / uv run;原第 11 节起顺延为 12
- requirements.txt: 由 uv export --no-dev --no-hashes 重新生成(锁定版本)
- 清理临时日志文件

验收:ruff check 全通过 / ruff format 26 文件无改动 / pyright 0 错误 /
unittest 97 项全部通过
This commit is contained in:
snowy-yang committed 2026-09-18 21:24:30 +08:00
1 parent fcd4cb4649
commit d7a7107b0a
11 files changed
+200 -74

No files matched your search

+25 -16
View File
@@ -9,6 +9,7 @@ TUI 会退化为无输入的只读模式。
from __future__ import annotations
import importlib.util
import os
import sys
@@ -40,28 +41,36 @@ class KeyReader:
"""把终端的原始字节流转换成按键名。"""
def __init__(self) -> None:
self.available = sys.stdin is not None and sys.stdin.isatty()
self._fd: int | None = None
self._saved_term = None
if self.available and not IS_WINDOWS:
try:
import termios
import tty
# 先把 stdin 取到局部变量:下面每处都要用它。同时它的类型被收窄到非 None,
# 省掉反复 `sys.stdin.xxx` 时"可能是 None"的隐患。
stdin = sys.stdin
if stdin is None or not stdin.isatty():
self.available = False
return
self.available = True
self._fd = sys.stdin.fileno()
self._saved_term = termios.tcgetattr(self._fd)
# cbreak: 立即返回按键,但保留 Ctrl-C 等信号
tty.setcbreak(self._fd)
except Exception:
if IS_WINDOWS:
# Windows 靠 msvcrt 做非阻塞读;拿不到就认为终端不可用
if importlib.util.find_spec("msvcrt") is None:
self.available = False
self._saved_term = None
return
if self.available and IS_WINDOWS:
try:
import msvcrt # noqa: F401
except Exception:
self.available = False
try:
import termios
import tty
# 用局部变量:self._fd 声明为 int | None,直接传进去会被判为可能为 None
fd = stdin.fileno()
self._fd = fd
self._saved_term = termios.tcgetattr(fd)
# cbreak: 立即返回按键,但保留 Ctrl-C 等信号
tty.setcbreak(fd)
except Exception:
self.available = False
self._saved_term = None
# ------------------------------------------------------------------ 读取
def poll(self) -> str | None:
+3 -6
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import argparse
import importlib.util
import os
import sys
@@ -97,12 +98,8 @@ def main(argv=None) -> int:
except ImportError:
print(dependency_hint(), file=sys.stderr)
return 2
try:
import websockets # noqa: F401 只是探测,uvicorn 需要它来做 WS 协议升级
ws_ok = True
except ImportError:
ws_ok = False
# 只探测"在不在",不真的导入——uvicorn 需要它才能完成 WebSocket 协议升级
ws_ok = importlib.util.find_spec("websockets") is not None
from .app import ServerConfig, create_app
+7 -6
View File
@@ -258,9 +258,7 @@ def describe_actions() -> list[dict[str, Any]]:
return [ACTIONS[name].to_dict() for name in action_names()]
def run_action(
engine: FarmEngine, name: Any, params: dict[str, Any] | None = None
) -> dict[str, Any]:
def run_action(engine: FarmEngine, name: Any, params: Any = None) -> dict[str, Any]:
"""校验参数并执行动作。
这是所有入口的**唯一**执行通道,因此输入校验只需要在这里做一次。
@@ -276,6 +274,8 @@ def run_action(
)
if params is None:
params = {}
# params 来自外部 JSON,类型标注是 Any 而不是 dict——这里的检查是**真的**校验,
# 不是多余防御(`params` 完全可能就是数组或字符串)
if not isinstance(params, dict):
raise GameError("bad_param", "params 必须是对象")
@@ -297,9 +297,10 @@ def run_action(
elif spec.default is not None:
clean[spec.name] = spec.default
result = action.handler(engine, clean)
if not isinstance(result, dict):
result = {"message": str(result)}
# handler 声明返回 dict,但这里是所有入口的唯一执行通道,仍然兜一层:
# 万一以后有 handler 返回了别的类型,也不会污染接口格式
raw: Any = action.handler(engine, clean)
result: dict[str, Any] = raw if isinstance(raw, dict) else {"message": str(raw)}
result.setdefault("ok", True)
result["action"] = key
return result
+5 -2
View File
@@ -18,7 +18,7 @@ import asyncio
import contextlib
import json
import time
from collections.abc import Callable
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -164,7 +164,10 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
started_at = time.time()
counters = {"requests": 0, "actions": 0, "ws_messages": 0, "errors": 0}
async def lifespan(_app: FastAPI) -> Any:
# 标注成 asynccontextmanager:FastAPI 的 lifespan 参数要的就是
# "返回异步上下文管理器",裸 async generator 在类型上并不匹配
@contextlib.asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
clock.start()
publisher.start()
# 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了"
+6 -3
View File
@@ -198,9 +198,12 @@ class TuiApp:
# ================================================================ 生命周期
def _enter(self) -> None:
if enable_ansi() and os.name == "nt":
# 个别终端不支持重新配置,失败就按原编码继续
with contextlib.suppress(Exception):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# 只有 TextIOWrapper 才有 reconfigure;被重定向时可能拿到别的流。
# 取不到就按原编码继续,不影响后续渲染。
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
with contextlib.suppress(Exception):
reconfigure(encoding="utf-8", errors="replace")
sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H")
sys.stdout.write(C_RESET)
sys.stdout.flush()