diff --git a/.gitignore b/.gitignore index e9e6b59..25399cb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,9 @@ venv/ run/ saves/ tests/_tmp/ -_uvpy.txt +# 仓库根目录下以 _ 开头的都是临时脚本/日志(补丁脚本、重定向输出、提交信息等), +# 用前导斜杠锚定根目录,否则 `_*` 会误伤 farm/__init__.py 这类文件。 +/_* # --------------------------------------------------------- 编辑器 / 系统 .vscode/ diff --git a/README.md b/README.md index 6573dce..cf79c79 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ ## 1. 快速开始 ```bash -pip install -r requirements.txt # 只有服务端需要第三方依赖 +uv sync # 建好环境(只有服务端需要第三方依赖) -python main.py serve --daemon # ① 后台启动服务器 -python main.py play --farm 我的农场 # ② 终端里玩(另开一个窗口) -python main.py bot --farm ai-1 # ③ 让机器人玩家一起经营 -python main.py status # ④ 看服务器状态 -python main.py stop # ⑤ 优雅停机(会把所有牧场存档) +uv run main.py serve --daemon # ① 后台启动服务器 +uv run main.py play --farm 我的农场 # ② 终端里玩(另开一个窗口) +uv run main.py bot --farm ai-1 # ③ 让机器人玩家一起经营 +uv run main.py status # ④ 看服务器状态 +uv run main.py stop # ⑤ 优雅停机(会把所有牧场存档) ``` 浏览器打开 就是网页版客户端(可多开几个标签页,用不同牧场名, @@ -43,9 +43,12 @@ python main.py stop # ⑤ 优雅停机(会把所有牧场 不想装任何第三方库、只想单机玩玩: ```bash -python main.py solo # 本地引擎 + 终端界面,不开服务器 +uv run main.py solo # 本地引擎 + 终端界面,不开服务器 ``` +> **没装 uv 也能跑**:把命令里的 `uv run ` 去掉,直接用 `python main.py ...`; +> 依赖用 `pip install fastapi 'uvicorn[standard]'` 装。两种方式行为完全一致。 + --- ## 2. 玩法 @@ -68,7 +71,7 @@ python main.py solo # 本地引擎 + 终端界面,不开 ## 3. 终端界面 -`python main.py play` 打开。核心交互: +`uv run main.py play` 打开。核心交互: ``` [↑↓←→]选地 [P]播种 [W]浇水 [H]收获 [E]全部收获 @@ -287,8 +290,8 @@ asyncio.run(main()) 局域网联机: ```bash -python 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 serve --daemon --host 0.0.0.0 --port 8765 --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.log`(`python main.py logs -n 50`) | -| 停止 | `python main.py stop`(先走 HTTP 优雅停机,失败再发信号) | -| 状态 | `python main.py status` | +| 日志 | `run/server.log`(`uv run main.py logs -n 50`) | +| 停止 | `uv run main.py stop`(先走 HTTP 优雅停机,失败再发信号) | +| 状态 | `uv run main.py status` | | 存档 | `saves/<牧场名>.json`(纯 JSON,可直接阅读分析) | | 接口文档 | | @@ -321,6 +324,10 @@ python main.py play --url http://192.168.1.20:8765 --farm alice --token 找个 ## 9. 代码结构 ``` +pyproject.toml 唯一真源:依赖、Python 版本、ruff 与 pyright 规则 +uv.lock 锁定的依赖版本(可复现构建,应当提交) +.python-version 钉住解释器版本 +.gitattributes 行尾统一为 LF,避免 Windows 上 CRLF 反复抖动 main.py 统一入口:serve / play / do / bot / status ... ai_player.py 外挂式机器人玩家(只用接口,不 import 任何游戏规则) farm/ @@ -358,9 +365,9 @@ tests/ ## 10. 测试 ```bash -python -m unittest discover -s tests -v # 全部(97 项) -python -m unittest tests.test_engine # 只测游戏规则,不需要 FastAPI -python -m unittest tests.test_server.TestLiveServer # 真起服务器 + 真 WebSocket +uv run python -m unittest discover -s tests -v # 全部(97 项) +uv run python -m unittest tests.test_engine # 只测游戏规则,不需要 FastAPI +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`): diff --git a/farm/keys.py b/farm/keys.py index cb29985..bd89404 100644 --- a/farm/keys.py +++ b/farm/keys.py @@ -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: diff --git a/farm/server/__main__.py b/farm/server/__main__.py index bb1e757..eab5f7c 100644 --- a/farm/server/__main__.py +++ b/farm/server/__main__.py @@ -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 diff --git a/farm/server/actions.py b/farm/server/actions.py index 49d14f3..8f9f27c 100644 --- a/farm/server/actions.py +++ b/farm/server/actions.py @@ -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 diff --git a/farm/server/app.py b/farm/server/app.py index d8a3f83..9fc7d48 100644 --- a/farm/server/app.py +++ b/farm/server/app.py @@ -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),可以对外宣告"我在了" diff --git a/farm/tui.py b/farm/tui.py index 9413ddb..b80522a 100644 --- a/farm/tui.py +++ b/farm/tui.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index d8df4d4..bfd5bbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,7 +112,13 @@ line-ending = "auto" # Windows/Linux 混用时不要强改行尾,避免 include = ["farm", "tests", "tools", "main.py", "ai_player.py"] exclude = [".venv", "**/__pycache__", ".ruff_cache", "saves", "run", "tests/_tmp"] pythonVersion = "3.10" +# 代码里有 termios / msvcrt 这类平台专属分支。只按当前平台分析, +# 会把另一侧的调用误报成"模块没有这个属性",所以让 pyright 同时考虑所有平台。 +pythonPlatform = "All" typeCheckingMode = "basic" reportMissingTypeStubs = false # 让"没装 third-party 存根"保持安静 reportUnusedImport = true reportUnnecessaryIsInstance = true +# __all__ 里写子模块名是 `farm/__init__.py` 惰性导出的正常写法, +# pyright 只认模块里真实存在的名字,这条对我们是纯噪音 +reportUnsupportedDunderAll = false diff --git a/requirements.txt b/requirements.txt index 43f26fb..2ec4615 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,56 @@ -# SimpleFarm 运行依赖 -# -# 服务器需要: -fastapi>=0.110 -uvicorn[standard]>=0.27 # 含 websockets,WebSocket 实时通道需要它 - -# 只有跑测试才需要(TestClient 依赖 httpx): -# httpx>=0.27 -# -# 装一次就好: -# pip install -r requirements.txt -# -# 注意:终端界面(TUI)与命令行客户端只用标准库, -# 所以即使不装上面这些,`python main.py solo / play / do` 依然能跑; -# 只有 `python main.py serve`(服务端)需要 FastAPI。 +# 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 + # via fastapi +annotated-types==0.8.0 + # via pydantic +anyio==4.15.1 + # via + # starlette + # watchfiles +click==8.5.0 + # via uvicorn +exceptiongroup==1.3.1 ; python_full_version < '3.11' + # via anyio +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 diff --git a/tests/test_server.py b/tests/test_server.py index 9eb7846..7c56494 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -13,6 +13,7 @@ from __future__ import annotations +import importlib.util import json import os 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 try: - import fastapi # noqa: F401 + # 能导入 TestClient 就说明 fastapi 在,不需要再单独 import 一次 from fastapi.testclient import TestClient HAS_FASTAPI = True except ImportError: # pragma: no cover - 取决于环境 HAS_FASTAPI = False -try: - import uvicorn # noqa: F401 - - HAS_UVICORN = True -except ImportError: # pragma: no cover - HAS_UVICORN = False +# uvicorn 只需要知道"在不在",不必真的导入(导入它是 TestLiveServer 里的事) +HAS_UVICORN = importlib.util.find_spec("uvicorn") is not None def free_port() -> int: diff --git a/tools/preview_tui.py b/tools/preview_tui.py index 002a6a0..56dc1d0 100644 --- a/tools/preview_tui.py +++ b/tools/preview_tui.py @@ -23,16 +23,20 @@ import os import random import re import sys +from typing import TYPE_CHECKING sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from farm.engine import FarmEngine from farm.tui import TuiApp +if TYPE_CHECKING: # 只在类型检查时导入,运行时不引入依赖 + from farm.client import RemoteFarm + 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