style: 引入 ruff,474 项告警清零

配置见 pyproject.toml:
- line-length=100:贴着现有代码定。若用默认的 88 会重排 122 行,
  噪音淹没真正有价值的修复。
- 启用 E/W/F/I/UP/B/C4/SIM/RET/PERF/RUF;**不**启用 PTH。因为本项目的路径
  多是"字符串身份"——要塞进 subprocess 命令行、写进 JSON 存档、打印给用户,
  全换 pathlib 只是包一层再拆一层,换来 70 处纯风格改动。
- 忽略 RUF001/002/003:中文全角标点是正确写法,不是"歧义字符"。
- 忽略 PERF203:剩下两处都是"逐项容错"语义(逐个牧场存档要立刻上报失败、
  收获全部要跳过失败地块)。把 try 提到循环外会毁掉"一项失败不影响其它项"
  这个行为,是错的优化。

自动修复 358 项:Dict/List/Optional 换成 PEP 585/604 写法、导入排序、
失效的 noqa、空占位 f-string 等。

手工修复的(这些是真问题,不是风格偏好):
- B904 x9:except 中 raise 补异常链。刻意区分——网络/IO 失败用 from exc 保留
  根因;参数解析失败用 from None,因为底层 ValueError 对玩家只是噪音。
- RUF012 x2:StateView 的 plots/animals 原本是**类级共享可变列表**,
  改成在 __init__ 里初始化(apply({}) 会提前返回,所以必须显式摆好)。
- F841:TUI 里一个赋值后再没用过的死变量。
- SIM105 x11:try/except/pass 统一改成 contextlib.suppress。
- SIM115:日志句柄改用 with。Popen 已把 fd 复制给子进程,父进程可安全关闭。
- E501 x7:拆超长行;main.py 那处顺手提取变量,避免超长嵌套表达式。
- E741:把 l/r 改成 left_line/right_line。
- 其余:RUF046 / PERF401 / RET504 / RUF005 / UP031 / B007。

ruff format 重排 24 个文件。

验证:97 项测试全绿。
This commit is contained in:
snowy-yang committed 2026-09-18 21:18:22 +08:00
1 parent c7c8a6308a
commit fcd4cb4649
25 files changed
+1204 -794

No files matched your search

+132 -105
View File
@@ -27,6 +27,7 @@ TUI 原本直接拿着 ``FarmEngine`` 对象,现在游戏跑在服务器进程
from __future__ import annotations
import asyncio
import contextlib
import json
import threading
import time
@@ -34,12 +35,13 @@ import urllib.error
import urllib.parse
import urllib.request
from collections import deque
from concurrent.futures import Future, TimeoutError as FutureTimeout
from typing import Any, Callable, Deque, Dict, List, Optional
from collections.abc import Callable
from concurrent.futures import Future
from concurrent.futures import TimeoutError as FutureTimeout
from typing import Any
from . import config
from . import config, net
from .engine import GameError
from . import net
DEFAULT_ENDPOINT = "http://127.0.0.1:8765"
LOG_KEEP = 200
@@ -52,7 +54,7 @@ class PlotView:
__slots__ = ("_d",)
def __init__(self, data: Dict[str, Any]) -> None:
def __init__(self, data: dict[str, Any]) -> None:
self._d = data
@property
@@ -64,7 +66,7 @@ class PlotView:
return int(self._d.get("position", self.index + 1))
@property
def crop(self) -> Optional[str]:
def crop(self) -> str | None:
return self._d.get("crop")
@property
@@ -99,7 +101,7 @@ class PlotView:
def stage(self) -> str:
return self._d.get("stage", "")
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return dict(self._d)
@@ -108,7 +110,7 @@ class AnimalView:
__slots__ = ("_d",)
def __init__(self, data: Dict[str, Any]) -> None:
def __init__(self, data: dict[str, Any]) -> None:
self._d = data
@property
@@ -155,18 +157,18 @@ class AnimalView:
def is_sick(self) -> bool:
return bool(self._d.get("is_sick"))
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return dict(self._d)
class StateView:
"""整局游戏的只读视图。刻意保留 ``to_dict()``,方便直接落盘或转发。"""
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
self._d: Dict[str, Any] = {}
def __init__(self, data: dict[str, Any] | None = None) -> None:
self._d: dict[str, Any] = {}
self.apply(data or {})
def apply(self, data: Dict[str, Any]) -> None:
def apply(self, data: dict[str, Any]) -> None:
"""用新数据刷新内部视图。
服务器会发两种东西:**完整快照**(带 plots/animals)和**精简状态**
@@ -210,7 +212,7 @@ class StateView:
return int(self._d.get("feed", 0))
@property
def market(self) -> Dict[str, float]:
def market(self) -> dict[str, float]:
return self._d.get("market", {})
def get(self, key: str, default: Any = None) -> Any:
@@ -219,21 +221,24 @@ class StateView:
def __getitem__(self, key: str) -> Any:
return self._d[key]
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return dict(self._d)
# 初始化占位,避免属性访问落空
plots: List[PlotView] = []
animals: List[AnimalView] = []
# --------------------------------------------------------------------- 客户端
class RemoteFarm:
"""远程农场的句柄:接口与 :class:`farm.engine.FarmEngine` 保持一致。"""
def __init__(self, endpoint: str = DEFAULT_ENDPOINT, farm: str = "default",
token: Optional[str] = None, name: str = "", timeout: float = 6.0,
realtime: bool = True, autostart: bool = True) -> None:
def __init__(
self,
endpoint: str = DEFAULT_ENDPOINT,
farm: str = "default",
token: str | None = None,
name: str = "",
timeout: float = 6.0,
realtime: bool = True,
autostart: bool = True,
) -> None:
self.endpoint = self._normalize(endpoint)
self.farm = farm or "default"
self.token = token or ""
@@ -243,24 +248,27 @@ class RemoteFarm:
self.state = StateView()
self.connected = False
self.mode = "http" # http / ws / poll
self.mode = "http" # http / ws / poll
self.last_error = ""
self.last_message = ""
self.server_info: Dict[str, Any] = {}
self.on_change: Optional[Callable[[], None]] = None
self.server_info: dict[str, Any] = {}
self.on_change: Callable[[], None] | None = None
self._lock = threading.RLock()
self._pending: Dict[int, Future] = {}
self._pending: dict[int, Future] = {}
self._req_id = 0
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._ws_socket: Any = None
self._ws_loop: Any = None
self._catalog: Dict[str, Any] = {"crops": [], "animals": [],
"feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST}
self._log: Deque[Dict[str, Any]] = deque(maxlen=LOG_KEEP)
self._catalog: dict[str, Any] = {
"crops": [],
"animals": [],
"feed_price": config.FEED_UNIT_PRICE,
"water_capacity": config.WATER_CAPACITY,
"ap_cost": config.AP_COST,
}
self._log: deque[dict[str, Any]] = deque(maxlen=LOG_KEEP)
self._log_seq = 0
self.stats = {"http": 0, "ws_actions": 0, "pushes": 0, "errors": 0}
@@ -274,7 +282,7 @@ class RemoteFarm:
def _normalize(endpoint: str) -> str:
endpoint = (endpoint or DEFAULT_ENDPOINT).strip().rstrip("/")
if endpoint.startswith("tcp://"):
endpoint = "http://" + endpoint[len("tcp://"):]
endpoint = "http://" + endpoint[len("tcp://") :]
if not endpoint.startswith(("http://", "https://")):
endpoint = "http://" + endpoint
return endpoint
@@ -290,8 +298,9 @@ class RemoteFarm:
query.update({k: v for k, v in params.items() if v is not None})
return f"{self.endpoint}{path}?{urllib.parse.urlencode(query)}"
def _http(self, method: str, path: str, payload: Optional[Dict[str, Any]] = None,
**params: Any) -> Dict[str, Any]:
def _http(
self, method: str, path: str, payload: dict[str, Any] | None = None, **params: Any
) -> dict[str, Any]:
url = self.url(path, **params)
data = None
headers = {"Accept": "application/json"}
@@ -313,14 +322,15 @@ class RemoteFarm:
self.connected = False
raise GameError(
"network",
f"连不上服务器 {self.endpoint}({exc})。"
"请先启动:python main.py serve --daemon",
f"连不上服务器 {self.endpoint}({exc})。请先启动:python main.py serve --daemon",
status=503,
)
) from exc
try:
data_out = json.loads(body) if body else {}
except json.JSONDecodeError:
raise GameError("bad_response", f"服务器返回了非 JSON 内容(HTTP {status})", 502)
except json.JSONDecodeError as exc:
raise GameError(
"bad_response", f"服务器返回了非 JSON 内容(HTTP {status})", 502
) from exc
if not isinstance(data_out, dict):
raise GameError("bad_response", "服务器返回的 JSON 不是对象", 502)
if not data_out.get("ok", True):
@@ -352,49 +362,48 @@ class RemoteFarm:
raise
return False
def _apply_online(self, state: Dict[str, Any], online: Any) -> None:
def _apply_online(self, state: dict[str, Any], online: Any) -> None:
"""把"在线人数"合进状态视图:TUI 头部和网页都要显示它。"""
if state and isinstance(online, int):
state = dict(state)
state["online"] = online
self._apply(state)
def snapshot(self) -> Dict[str, Any]:
def snapshot(self) -> dict[str, Any]:
return self.state.to_dict()
def catalog(self) -> Dict[str, Any]:
def catalog(self) -> dict[str, Any]:
return self._catalog
def recent_log(self, limit: int = 12, since: int = 0) -> List[Dict[str, Any]]:
def recent_log(self, limit: int = 12, since: int = 0) -> list[dict[str, Any]]:
with self._lock:
entries = [e for e in self._log if int(e.get("seq", 0)) > since]
return entries[-limit:] if limit > 0 else entries
def available_actions(self) -> List[Dict[str, Any]]:
def available_actions(self) -> list[dict[str, Any]]:
data = self._http("GET", "/api/actions")
self._apply(data.get("state") or {})
return data.get("actions") or []
def leaderboard(self) -> List[Dict[str, Any]]:
def leaderboard(self) -> list[dict[str, Any]]:
return self._http("GET", "/api/farms").get("farms") or []
def world(self) -> Dict[str, Any]:
def world(self) -> dict[str, Any]:
return self._http("GET", "/api/world")
def help_text(self) -> str:
return self._http("GET", "/api/help").get("rules", "")
def _apply(self, state: Dict[str, Any]) -> None:
def _apply(self, state: dict[str, Any]) -> None:
if not state:
return
self.state.apply(state)
if self.on_change is not None:
try:
# UI 回调抛异常不能影响数据同步
with contextlib.suppress(Exception):
self.on_change()
except Exception:
pass
def _merge_log(self, entries: List[Dict[str, Any]]) -> None:
def _merge_log(self, entries: list[dict[str, Any]]) -> None:
with self._lock:
known = {int(e.get("seq", 0)) for e in self._log}
for entry in entries:
@@ -403,12 +412,14 @@ class RemoteFarm:
self._log.append(entry)
known.add(seq)
self._log_seq = max(self._log_seq, seq)
self._log = deque(sorted(self._log, key=lambda e: int(e.get("seq", 0))),
maxlen=LOG_KEEP)
self._log = deque(
sorted(self._log, key=lambda e: int(e.get("seq", 0))), maxlen=LOG_KEEP
)
# ------------------------------------------------------------ 写接口
def _action(self, action: str, params: Optional[Dict[str, Any]] = None,
transport: str = "auto") -> Dict[str, Any]:
def _action(
self, action: str, params: dict[str, Any] | None = None, transport: str = "auto"
) -> dict[str, Any]:
"""执行一个动作。
优先走 WebSocket(延迟更低、顺便复用已有连接);只有**通道类**错误
@@ -416,8 +427,9 @@ class RemoteFarm:
否则"金币不足"会被悄悄重试成两次操作。
"""
params = params or {}
use_ws = (transport == "ws") or (transport == "auto" and self.mode == "ws"
and self.connected)
use_ws = (transport == "ws") or (
transport == "auto" and self.mode == "ws" and self.connected
)
if use_ws:
try:
result = self._ws_action(action, params)
@@ -436,48 +448,47 @@ class RemoteFarm:
return result
# full=1:让服务器顺手把完整快照带回来,省掉"操作后再查一次"
data = self._http("POST", "/api/actions", {"action": action, "params": params},
full=1)
data = self._http("POST", "/api/actions", {"action": action, "params": params}, full=1)
self._apply(data.get("snapshot") or data.get("state") or {})
self._merge_log(data.get("log") or [])
return data
# ---- 与本地引擎同名的方法
def plant(self, plot: Any, crop: Any) -> Dict[str, Any]:
def plant(self, plot: Any, crop: Any) -> dict[str, Any]:
return self._action("plant", {"plot": plot, "crop": crop})
def water(self, plot: Any) -> Dict[str, Any]:
def water(self, plot: Any) -> dict[str, Any]:
return self._action("water", {"plot": plot})
def harvest(self, plot: Any) -> Dict[str, Any]:
def harvest(self, plot: Any) -> dict[str, Any]:
return self._action("harvest", {"plot": plot})
def harvest_all(self) -> Dict[str, Any]:
def harvest_all(self) -> dict[str, Any]:
return self._action("harvest_all")
def buy_animal(self, kind: Any) -> Dict[str, Any]:
def buy_animal(self, kind: Any) -> dict[str, Any]:
return self._action("buy_animal", {"kind": kind})
def feed(self, animal_id: Any, quantity: Any = None) -> Dict[str, Any]:
params: Dict[str, Any] = {"animal_id": animal_id}
def feed(self, animal_id: Any, quantity: Any = None) -> dict[str, Any]:
params: dict[str, Any] = {"animal_id": animal_id}
if quantity is not None:
params["quantity"] = quantity
return self._action("feed", params)
def collect(self, animal_id: Any) -> Dict[str, Any]:
def collect(self, animal_id: Any) -> dict[str, Any]:
return self._action("collect", {"animal_id": animal_id})
def buy_feed(self, quantity: Any = config.FEED_BULK) -> Dict[str, Any]:
def buy_feed(self, quantity: Any = config.FEED_BULK) -> dict[str, Any]:
return self._action("buy_feed", {"quantity": quantity})
def buy_plot(self) -> Dict[str, Any]:
def buy_plot(self) -> dict[str, Any]:
return self._action("buy_plot")
def sleep(self) -> Dict[str, Any]:
def sleep(self) -> dict[str, Any]:
return self._action("sleep")
def pause(self, paused: Optional[bool] = None) -> Dict[str, Any]:
params: Dict[str, Any] = {}
def pause(self, paused: bool | None = None) -> dict[str, Any]:
params: dict[str, Any] = {}
if paused is not None:
params["paused"] = paused
data = self._action("pause", params)
@@ -490,7 +501,7 @@ class RemoteFarm:
self.state.apply(merged)
return data
def reset(self) -> Dict[str, Any]:
def reset(self) -> dict[str, Any]:
data = self._action("reset")
with self._lock:
self._log.clear()
@@ -498,11 +509,11 @@ class RemoteFarm:
self.refresh()
return data
def save(self, path: Optional[str] = None) -> Dict[str, Any]:
def save(self, path: str | None = None) -> dict[str, Any]:
"""远程存档:位置由服务器决定,``path`` 参数仅为与本地引擎保持同签名。"""
return self._action("save")
def batch(self, items: List[Dict[str, Any]]) -> Dict[str, Any]:
def batch(self, items: list[dict[str, Any]]) -> dict[str, Any]:
"""批量执行(一个请求干多件事),给机器人/AI 用。"""
data = self._http("POST", "/api/batch", {"actions": items}, full=1)
self._apply(data.get("snapshot") or data.get("state") or {})
@@ -514,8 +525,7 @@ class RemoteFarm:
if not self.realtime_enabled or self._thread is not None:
return
self._stop.clear()
self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws",
daemon=True)
self._thread = threading.Thread(target=self._realtime_loop, name="farm-ws", daemon=True)
self._thread.start()
def stop(self) -> None:
@@ -586,9 +596,13 @@ class RemoteFarm:
asyncio.run(self._ws_main(websockets))
async def _ws_main(self, websockets) -> None:
async with websockets.connect(self.ws_url, open_timeout=self.timeout,
ping_interval=20, ping_timeout=20,
max_size=4 * 1024 * 1024) as socket:
async with websockets.connect(
self.ws_url,
open_timeout=self.timeout,
ping_interval=20,
ping_timeout=20,
max_size=4 * 1024 * 1024,
) as socket:
self.mode = "ws"
self.connected = True
self.last_error = ""
@@ -650,37 +664,48 @@ class RemoteFarm:
return
if message.get("ok"):
self._merge_log(message.get("log") or [])
future.set_result({
"ok": True,
"message": message.get("message", "完成"),
**(message.get("result") or {}),
"state": message.get("state"),
"snapshot": message.get("snapshot"),
"log": message.get("log"),
})
future.set_result(
{
"ok": True,
"message": message.get("message", "完成"),
**(message.get("result") or {}),
"state": message.get("state"),
"snapshot": message.get("snapshot"),
"log": message.get("log"),
}
)
else:
future.set_exception(GameError(message.get("error", "error"),
message.get("message", "操作失败")))
future.set_exception(
GameError(message.get("error", "error"), message.get("message", "操作失败"))
)
return
if kind == "day":
self._merge_log([{
"seq": self._log_seq + 1,
"day": message.get("day", 0),
"kind": "info",
"text": f"第 {message.get('day')} 天开始",
}] + [{
"seq": self._log_seq + 2 + i,
"day": message.get("day", 0),
"kind": "warn",
"text": text,
} for i, text in enumerate(message.get("events") or [])])
self._merge_log(
[
{
"seq": self._log_seq + 1,
"day": message.get("day", 0),
"kind": "info",
"text": f"第 {message.get('day')} 天开始",
}
]
+ [
{
"seq": self._log_seq + 2 + i,
"day": message.get("day", 0),
"kind": "warn",
"text": text,
}
for i, text in enumerate(message.get("events") or [])
]
)
return
if kind == "error":
self.last_error = message.get("message", "")
def _ws_send(self, payload: Dict[str, Any]) -> None:
def _ws_send(self, payload: dict[str, Any]) -> None:
"""把消息发到 WS 线程的事件循环里(跨线程投递)。"""
socket = getattr(self, "_ws_socket", None)
loop = getattr(self, "_ws_loop", None)
@@ -688,8 +713,9 @@ class RemoteFarm:
raise GameError("ws_closed", "WebSocket 尚未连接")
asyncio.run_coroutine_threadsafe(socket.send(json.dumps(payload)), loop)
def _ws_action(self, action: str, params: Dict[str, Any],
timeout: Optional[float] = None) -> Dict[str, Any]:
def _ws_action(
self, action: str, params: dict[str, Any], timeout: float | None = None
) -> dict[str, Any]:
with self._lock:
self._req_id += 1
rid = self._req_id
@@ -706,7 +732,8 @@ class RemoteFarm:
except FutureTimeout:
with self._lock:
self._pending.pop(rid, None)
raise GameError("timeout", "服务器没有在超时时间内回应")
# 超时是正常控制流:FutureTimeout 是内部实现细节,不必挂到异常链上
raise GameError("timeout", "服务器没有在超时时间内回应") from None
class RemoteBotClient(RemoteFarm):