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
+194 -68

No files matched your search

+3 -1
View File
@@ -24,7 +24,9 @@ venv/
run/ run/
saves/ saves/
tests/_tmp/ tests/_tmp/
_uvpy.txt # 仓库根目录下以 _ 开头的都是临时脚本/日志(补丁脚本、重定向输出、提交信息等),
# 用前导斜杠锚定根目录,否则 `_*` 会误伤 farm/__init__.py 这类文件。
/_*
# --------------------------------------------------------- 编辑器 / 系统 # --------------------------------------------------------- 编辑器 / 系统
.vscode/ .vscode/
+80 -17
View File
@@ -28,13 +28,13 @@
## 1. 快速开始 ## 1. 快速开始
```bash ```bash
pip install -r requirements.txt # 只有服务端需要第三方依赖 uv sync # 建好环境(只有服务端需要第三方依赖)
python main.py serve --daemon # ① 后台启动服务器 uv run main.py serve --daemon # ① 后台启动服务器
python main.py play --farm 我的农场 # ② 终端里玩(另开一个窗口) uv run main.py play --farm 我的农场 # ② 终端里玩(另开一个窗口)
python main.py bot --farm ai-1 # ③ 让机器人玩家一起经营 uv run main.py bot --farm ai-1 # ③ 让机器人玩家一起经营
python main.py status # ④ 看服务器状态 uv run main.py status # ④ 看服务器状态
python main.py stop # ⑤ 优雅停机(会把所有牧场存档) uv run main.py stop # ⑤ 优雅停机(会把所有牧场存档)
``` ```
浏览器打开 <http://127.0.0.1:8765/> 就是网页版客户端(可多开几个标签页,用不同牧场名, 浏览器打开 <http://127.0.0.1:8765/> 就是网页版客户端(可多开几个标签页,用不同牧场名,
@@ -43,9 +43,12 @@ python main.py stop # ⑤ 优雅停机(会把所有牧场
不想装任何第三方库、只想单机玩玩: 不想装任何第三方库、只想单机玩玩:
```bash ```bash
python main.py solo # 本地引擎 + 终端界面,不开服务器 uv run main.py solo # 本地引擎 + 终端界面,不开服务器
``` ```
> **没装 uv 也能跑**:把命令里的 `uv run ` 去掉,直接用 `python main.py ...`;
> 依赖用 `pip install fastapi 'uvicorn[standard]'` 装。两种方式行为完全一致。
--- ---
## 2. 玩法 ## 2. 玩法
@@ -68,7 +71,7 @@ python main.py solo # 本地引擎 + 终端界面,不开
## 3. 终端界面 ## 3. 终端界面
`python main.py play` 打开。核心交互: `uv run main.py play` 打开。核心交互:
``` ```
[↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获 [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获
@@ -287,8 +290,8 @@ asyncio.run(main())
局域网联机: 局域网联机:
```bash ```bash
python main.py serve --daemon --host 0.0.0.0 --port 8765 --token 找个密码 uv run main.py serve --daemon --host 0.0.0.0 --port 8765 --token 找个密码
python main.py play --url http://192.168.1.20:8765 --farm alice --token 找个密码 uv run main.py play --url http://192.168.1.20:8765 --farm alice --token 找个密码
``` ```
--- ---
@@ -300,9 +303,9 @@ python main.py play --url http://192.168.1.20:8765 --farm alice --token 找个
| 项目 | 位置 / 命令 | | 项目 | 位置 / 命令 |
| --- | --- | | --- | --- |
| 进程号 | `run/server.pid`(**由服务进程自己写**,见下方说明) | | 进程号 | `run/server.pid`(**由服务进程自己写**,见下方说明) |
| 日志 | `run/server.log`(`python main.py logs -n 50`) | | 日志 | `run/server.log`(`uv run main.py logs -n 50`) |
| 停止 | `python main.py stop`(先走 HTTP 优雅停机,失败再发信号) | | 停止 | `uv run main.py stop`(先走 HTTP 优雅停机,失败再发信号) |
| 状态 | `python main.py status` | | 状态 | `uv run main.py status` |
| 存档 | `saves/<牧场名>.json`(纯 JSON,可直接阅读分析) | | 存档 | `saves/<牧场名>.json`(纯 JSON,可直接阅读分析) |
| 接口文档 | <http://127.0.0.1:8765/docs> | | 接口文档 | <http://127.0.0.1:8765/docs> |
@@ -321,6 +324,10 @@ python main.py play --url http://192.168.1.20:8765 --farm alice --token 找个
## 9. 代码结构 ## 9. 代码结构
``` ```
pyproject.toml 唯一真源:依赖、Python 版本、ruff 与 pyright 规则
uv.lock 锁定的依赖版本(可复现构建,应当提交)
.python-version 钉住解释器版本
.gitattributes 行尾统一为 LF,避免 Windows 上 CRLF 反复抖动
main.py 统一入口:serve / play / do / bot / status ... main.py 统一入口:serve / play / do / bot / status ...
ai_player.py 外挂式机器人玩家(只用接口,不 import 任何游戏规则) ai_player.py 外挂式机器人玩家(只用接口,不 import 任何游戏规则)
farm/ farm/
@@ -358,9 +365,9 @@ tests/
## 10. 测试 ## 10. 测试
```bash ```bash
python -m unittest discover -s tests -v # 全部(97 项) uv run python -m unittest discover -s tests -v # 全部(97 项)
python -m unittest tests.test_engine # 只测游戏规则,不需要 FastAPI uv run python -m unittest tests.test_engine # 只测游戏规则,不需要 FastAPI
python -m unittest tests.test_server.TestLiveServer # 真起服务器 + 真 WebSocket uv run python -m unittest tests.test_server.TestLiveServer # 真起服务器 + 真 WebSocket
``` ```
覆盖范围: 覆盖范围:
@@ -386,7 +393,63 @@ serve --daemon → status 显示正确 pid → actions 列出 13 个候选动作
--- ---
## 11. 边界处理与设计取舍 ## 11. 工程化:uv + ruff + pyright
依赖版本、Python 版本、lint 规则、类型检查规则**全部**写在 `pyproject.toml` 里——
单一真源,不再存在"requirements 与实际环境不一致"这种事。`uv.lock` 记录完整解析结果,
提交进版本库,换台机器 `uv sync` 得到的是同一个环境。
| 目的 | 命令 |
| --- | --- |
| 同步环境(含开发依赖) | `uv sync` |
| 只装运行依赖 | `uv sync --no-dev` |
| 添加依赖 | `uv add <包>` / `uv add --dev <包>` |
| 升级锁定版本 | `uv lock --upgrade` |
| 导出给 pip 用户 | `uv export --no-dev --no-hashes -o requirements.txt` |
| 静态检查 | `uv run ruff check .` |
| 自动修复 | `uv run ruff check . --fix` |
| 格式化 | `uv run ruff format .` |
| 类型检查 | `uv run pyright` |
### 为什么是 uv
* **一个工具顶四个**:解释器管理、依赖解析、虚拟环境、命令运行都在里面,
不需要 pyenv + pip + venv + pip-tools 拼装;
* **可复现**:`uv.lock` 精确到哈希,跨机器一致;
* **不用激活环境**:`uv run <命令>` 自动使用项目环境,少一类"我明明装了"的事故。
### ruff 的取舍
`ruff check` 管正确性,`ruff format` 管排版,一个工具替代
Black + isort + flake8 三件套(避免"格式化完 lint 又报"的拉锯)。
启用 `E W F I UP B C4 SIM RET PERF RUF`。两个刻意的**不**:
* **不启用 PTH**(`os.path` → `pathlib`)。本项目的路径大多是"字符串身份":
要拼进 `subprocess` 命令行、写进 JSON 存档、打印给用户看。全换成 `Path`
只是把字符串包一层再拆一层,换来 70 处纯风格改动,反而淹没真正的问题。
* **忽略 RUF001/002/003**。它们会把中文全角标点当成"含糊的 Unicode 字符",
但这里的注释与界面文案本来就是中文,全角括号是正确写法。
另外忽略 `PERF203`(循环里的 `try`):保留下来的两处都是**逐项容错**语义
(逐个牧场存档要立刻上报失败、收获全部要跳过失败地块)。把 `try` 提到循环外
会让"一项失败不影响其它项"这个行为消失——那是错的优化。
`line-length = 100` 是**贴着现有代码**定的:全项目只有 18 行超过 100 列;
若采用默认的 88,需要额外重排 122 行,diff 噪音会淹没真实改动。
### pyright 的取舍
用 `basic` 模式。目标不是"消灭所有 Unknown",而是**能在 CI 里拦住真 bug**:
None 误用、属性不存在、参数类型不符。本项目的状态快照是 `dict[str, Any]` 结构,
开 `strict` 去追每个 `Unknown` 只会得到一堆噪音。
配合 `pythonVersion = "3.10"`,守住 `requires-python` 的下限——
哪天不小心用上 3.11+ 独有语法,会立刻报错。
---
## 12. 边界处理与设计取舍
**输入校验**(唯一入口 `server/actions.py`): **输入校验**(唯一入口 `server/actions.py`):
+20 -11
View File
@@ -9,6 +9,7 @@ TUI 会退化为无输入的只读模式。
from __future__ import annotations from __future__ import annotations
import importlib.util
import os import os
import sys import sys
@@ -40,29 +41,37 @@ class KeyReader:
"""把终端的原始字节流转换成按键名。""" """把终端的原始字节流转换成按键名。"""
def __init__(self) -> None: def __init__(self) -> None:
self.available = sys.stdin is not None and sys.stdin.isatty()
self._fd: int | None = None self._fd: int | None = None
self._saved_term = None self._saved_term = None
if self.available and not IS_WINDOWS: # 先把 stdin 取到局部变量:下面每处都要用它。同时它的类型被收窄到非 None,
# 省掉反复 `sys.stdin.xxx` 时"可能是 None"的隐患。
stdin = sys.stdin
if stdin is None or not stdin.isatty():
self.available = False
return
self.available = True
if IS_WINDOWS:
# Windows 靠 msvcrt 做非阻塞读;拿不到就认为终端不可用
if importlib.util.find_spec("msvcrt") is None:
self.available = False
return
try: try:
import termios import termios
import tty import tty
self._fd = sys.stdin.fileno() # 用局部变量:self._fd 声明为 int | None,直接传进去会被判为可能为 None
self._saved_term = termios.tcgetattr(self._fd) fd = stdin.fileno()
self._fd = fd
self._saved_term = termios.tcgetattr(fd)
# cbreak: 立即返回按键,但保留 Ctrl-C 等信号 # cbreak: 立即返回按键,但保留 Ctrl-C 等信号
tty.setcbreak(self._fd) tty.setcbreak(fd)
except Exception: except Exception:
self.available = False self.available = False
self._saved_term = None self._saved_term = None
if self.available and IS_WINDOWS:
try:
import msvcrt # noqa: F401
except Exception:
self.available = False
# ------------------------------------------------------------------ 读取 # ------------------------------------------------------------------ 读取
def poll(self) -> str | None: def poll(self) -> str | None:
"""非阻塞读取一个按键;没有按键时返回 None。""" """非阻塞读取一个按键;没有按键时返回 None。"""
+3 -6
View File
@@ -7,6 +7,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import importlib.util
import os import os
import sys import sys
@@ -97,12 +98,8 @@ def main(argv=None) -> int:
except ImportError: except ImportError:
print(dependency_hint(), file=sys.stderr) print(dependency_hint(), file=sys.stderr)
return 2 return 2
try: # 只探测"在不在",不真的导入——uvicorn 需要它才能完成 WebSocket 协议升级
import websockets # noqa: F401 只是探测,uvicorn 需要它来做 WS 协议升级 ws_ok = importlib.util.find_spec("websockets") is not None
ws_ok = True
except ImportError:
ws_ok = False
from .app import ServerConfig, create_app 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()] return [ACTIONS[name].to_dict() for name in action_names()]
def run_action( def run_action(engine: FarmEngine, name: Any, params: Any = None) -> dict[str, Any]:
engine: FarmEngine, name: Any, params: dict[str, Any] | None = None
) -> dict[str, Any]:
"""校验参数并执行动作。 """校验参数并执行动作。
这是所有入口的**唯一**执行通道,因此输入校验只需要在这里做一次。 这是所有入口的**唯一**执行通道,因此输入校验只需要在这里做一次。
@@ -276,6 +274,8 @@ def run_action(
) )
if params is None: if params is None:
params = {} params = {}
# params 来自外部 JSON,类型标注是 Any 而不是 dict——这里的检查是**真的**校验,
# 不是多余防御(`params` 完全可能就是数组或字符串)
if not isinstance(params, dict): if not isinstance(params, dict):
raise GameError("bad_param", "params 必须是对象") raise GameError("bad_param", "params 必须是对象")
@@ -297,9 +297,10 @@ def run_action(
elif spec.default is not None: elif spec.default is not None:
clean[spec.name] = spec.default clean[spec.name] = spec.default
result = action.handler(engine, clean) # handler 声明返回 dict,但这里是所有入口的唯一执行通道,仍然兜一层:
if not isinstance(result, dict): # 万一以后有 handler 返回了别的类型,也不会污染接口格式
result = {"message": str(result)} raw: Any = action.handler(engine, clean)
result: dict[str, Any] = raw if isinstance(raw, dict) else {"message": str(raw)}
result.setdefault("ok", True) result.setdefault("ok", True)
result["action"] = key result["action"] = key
return result return result
+5 -2
View File
@@ -18,7 +18,7 @@ import asyncio
import contextlib import contextlib
import json import json
import time import time
from collections.abc import Callable from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -164,7 +164,10 @@ def create_app(cfg: ServerConfig | None = None) -> FastAPI:
started_at = time.time() started_at = time.time()
counters = {"requests": 0, "actions": 0, "ws_messages": 0, "errors": 0} 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() clock.start()
publisher.start() publisher.start()
# 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了" # 端口已经绑定成功(uvicorn 先 bind 再跑 lifespan),可以对外宣告"我在了"
+5 -2
View File
@@ -198,9 +198,12 @@ class TuiApp:
# ================================================================ 生命周期 # ================================================================ 生命周期
def _enter(self) -> None: def _enter(self) -> None:
if enable_ansi() and os.name == "nt": if enable_ansi() and os.name == "nt":
# 个别终端不支持重新配置,失败就按原编码继续 # 只有 TextIOWrapper 才有 reconfigure;被重定向时可能拿到别的流。
# 取不到就按原编码继续,不影响后续渲染。
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
sys.stdout.reconfigure(encoding="utf-8", errors="replace") reconfigure(encoding="utf-8", errors="replace")
sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H") sys.stdout.write("\x1b[?25l\x1b[2J\x1b[H")
sys.stdout.write(C_RESET) sys.stdout.write(C_RESET)
sys.stdout.flush() sys.stdout.flush()
+6
View File
@@ -112,7 +112,13 @@ line-ending = "auto" # Windows/Linux 混用时不要强改行尾,避免
include = ["farm", "tests", "tools", "main.py", "ai_player.py"] include = ["farm", "tests", "tools", "main.py", "ai_player.py"]
exclude = [".venv", "**/__pycache__", ".ruff_cache", "saves", "run", "tests/_tmp"] exclude = [".venv", "**/__pycache__", ".ruff_cache", "saves", "run", "tests/_tmp"]
pythonVersion = "3.10" pythonVersion = "3.10"
# 代码里有 termios / msvcrt 这类平台专属分支。只按当前平台分析,
# 会把另一侧的调用误报成"模块没有这个属性",所以让 pyright 同时考虑所有平台。
pythonPlatform = "All"
typeCheckingMode = "basic" typeCheckingMode = "basic"
reportMissingTypeStubs = false # 让"没装 third-party 存根"保持安静 reportMissingTypeStubs = false # 让"没装 third-party 存根"保持安静
reportUnusedImport = true reportUnusedImport = true
reportUnnecessaryIsInstance = true reportUnnecessaryIsInstance = true
# __all__ 里写子模块名是 `farm/__init__.py` 惰性导出的正常写法,
# pyright 只认模块里真实存在的名字,这条对我们是纯噪音
reportUnsupportedDunderAll = false
+56 -15
View File
@@ -1,15 +1,56 @@
# SimpleFarm 运行依赖 # This file was autogenerated by uv via the following command:
# # uv export --no-dev --no-hashes --output-file requirements.txt
# 服务器需要: annotated-doc==0.0.5
fastapi>=0.110 # via fastapi
uvicorn[standard]>=0.27 # 含 websockets,WebSocket 实时通道需要它 annotated-types==0.8.0
# via pydantic
# 只有跑测试才需要(TestClient 依赖 httpx): anyio==4.15.1
# httpx>=0.27 # via
# # starlette
# 装一次就好: # watchfiles
# pip install -r requirements.txt click==8.5.0
# # via uvicorn
# 注意:终端界面(TUI)与命令行客户端只用标准库, exceptiongroup==1.3.1 ; python_full_version < '3.11'
# 所以即使不装上面这些,`python main.py solo / play / do` 依然能跑; # via anyio
# 只有 `python main.py serve`(服务端)需要 FastAPI。 fastapi==0.141.1
# via simplefarm
h11==0.16.0
# via uvicorn
httptools==0.8.0
# via uvicorn
idna==3.20
# via anyio
pydantic==2.13.5
# via fastapi
pydantic-core==2.46.5
# via pydantic
python-dotenv==1.2.3
# via uvicorn
pyyaml==6.0.3
# via uvicorn
starlette==1.6.0
# via fastapi
typing-extensions==4.16.0
# via
# anyio
# exceptiongroup
# fastapi
# pydantic
# pydantic-core
# starlette
# typing-inspection
# uvicorn
typing-inspection==0.4.4
# via
# fastapi
# pydantic
uvicorn==0.53.0
# via simplefarm
uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
# via uvicorn
watchfiles==1.2.0
# via uvicorn
websockets==16.1.1 ; python_full_version < '3.11'
# via uvicorn
websockets==17.1 ; python_full_version >= '3.11'
# via uvicorn
+4 -7
View File
@@ -13,6 +13,7 @@
from __future__ import annotations from __future__ import annotations
import importlib.util
import json import json
import os import os
import shutil import shutil
@@ -28,19 +29,15 @@ from farm.server.actions import ACTIONS, describe_actions, run_action
from farm.server.session import SessionManager, validate_farm_id from farm.server.session import SessionManager, validate_farm_id
try: try:
import fastapi # noqa: F401 # 能导入 TestClient 就说明 fastapi 在,不需要再单独 import 一次
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
HAS_FASTAPI = True HAS_FASTAPI = True
except ImportError: # pragma: no cover - 取决于环境 except ImportError: # pragma: no cover - 取决于环境
HAS_FASTAPI = False HAS_FASTAPI = False
try: # uvicorn 只需要知道"在不在",不必真的导入(导入它是 TestLiveServer 里的事)
import uvicorn # noqa: F401 HAS_UVICORN = importlib.util.find_spec("uvicorn") is not None
HAS_UVICORN = True
except ImportError: # pragma: no cover
HAS_UVICORN = False
def free_port() -> int: def free_port() -> int:
+5 -1
View File
@@ -23,16 +23,20 @@ import os
import random import random
import re import re
import sys import sys
from typing import TYPE_CHECKING
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from farm.engine import FarmEngine from farm.engine import FarmEngine
from farm.tui import TuiApp from farm.tui import TuiApp
if TYPE_CHECKING: # 只在类型检查时导入,运行时不引入依赖
from farm.client import RemoteFarm
SCENES = ("empty", "mixed", "rich", "menu", "help") SCENES = ("empty", "mixed", "rich", "menu", "help")
def build_remote(url: str, farm: str, token: str | None) -> tuple[object, TuiApp]: def build_remote(url: str, farm: str, token: str | None) -> tuple[RemoteFarm, TuiApp]:
"""连到真实服务器,取一份现状来渲染。""" """连到真实服务器,取一份现状来渲染。"""
from farm.client import RemoteFarm from farm.client import RemoteFarm