fix: 修复 7 处变量可能未绑定的真缺陷,类型检查升到 standard

为什么:IDE 里成片的黄色波浪线并非风格噪声。ruff 与 pyright(basic) 都是
0 报错,但 basic 会整体漏报「变量可能未绑定」这类**可执行缺陷**;实测
standard 多报 7 处且全部为真——异常处理器引用了尚未赋值的局部变量,一旦走进
异常分支就会 NameError 二次崩溃,把原始异常彻底掩盖。

- core/db.py, core/http_client.py:3 处 @asynccontextmanager 的返回标注
  AsyncIterator[X] 更正为 AsyncGenerator[X, None](前者不含 send 语义)
- core/state.py:三个模块级缓存补 TypedDict 建模。原先无标注的 dict 字面量被
  推断为「所有值类型的并集」(float | None),每个读写点都退化成联合类型;
  拆成 _VipCache(data 可为 None 表示未探测)与 _VipEntry(data 必定存在)后,
  类型检查立刻抓出 account.py 对未探测缓存的直接解包隐患
- services/batch.py:zip_valid 提到函数头。ZIP 构建前的业务错误(参数缺失/
  无歌曲)会 early raise,而异常处理器引用了该名字 → NameError 二次崩溃
- services/podcast.py:radio_name、pct 同理补初始化
- services/downloads.py:声明返回 str 却可能给出 None,改 `or ''` 满足契约
- web/routers/download.py:id_list 提前绑定
- web/auth_state.py:解包出的 c 未被使用
- pyproject.toml:typeCheckingMode 由 basic 升到 standard 作为门禁,并移除
  已被 standard 覆盖的 reportGeneralTypeIssues

门禁:ruff All checks passed · pyright(standard) 0 errors 0 warnings
This commit is contained in:
snowy-yang committed 2026-09-26 13:54:23 +08:00
1 parent fc7fc21284
commit 183f5744d8
9 files changed
+58 -16

No files matched your search

+3 -3
View File
@@ -14,7 +14,7 @@
import asyncio import asyncio
import os import os
from collections.abc import AsyncIterator, Iterable, Sequence from collections.abc import AsyncGenerator, Iterable, Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -135,7 +135,7 @@ async def execute_many(sql: str, seq: Iterable[Sequence]) -> int:
@asynccontextmanager @asynccontextmanager
async def write_transaction() -> AsyncIterator[aiosqlite.Connection]: async def write_transaction() -> AsyncGenerator[aiosqlite.Connection, None]:
"""写事务上下文:多条写语句原子提交(串行锁内)。 """写事务上下文:多条写语句原子提交(串行锁内)。
用法: 用法:
@@ -157,7 +157,7 @@ async def write_transaction() -> AsyncIterator[aiosqlite.Connection]:
@asynccontextmanager @asynccontextmanager
async def read_connection() -> AsyncIterator[aiosqlite.Connection]: async def read_connection() -> AsyncGenerator[aiosqlite.Connection, None]:
"""只读连接上下文(无锁、可并发);需要在一个连接上做多次读时使用。""" """只读连接上下文(无锁、可并发);需要在一个连接上做多次读时使用。"""
conn = await connect() conn = await connect()
try: try:
+2 -2
View File
@@ -15,7 +15,7 @@
import asyncio import asyncio
import json import json
from collections.abc import AsyncIterator, Mapping from collections.abc import AsyncGenerator, Mapping
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -235,7 +235,7 @@ class AsyncHTTPClient:
cookies: Mapping[str, str] | None = None, cookies: Mapping[str, str] | None = None,
timeout: float | None = None, timeout: float | None = None,
allow_redirects: bool = True, allow_redirects: bool = True,
) -> AsyncIterator[aiohttp.ClientResponse]: ) -> AsyncGenerator[aiohttp.ClientResponse, None]:
"""流式请求(下载/音视频代理用):yield 原始响应,边读边写不整段入内存。""" """流式请求(下载/音视频代理用):yield 原始响应,边读边写不整段入内存。"""
session = await self._session_for_loop() session = await self._session_for_loop()
req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
+35 -5
View File
@@ -15,7 +15,7 @@ web/ 与各服务模块共用,并为后续封装成带锁的单例(State 对
import collections import collections
import threading import threading
from typing import Any from typing import Any, NotRequired, TypedDict
# ============ 短链展开结果缓存 ============ # ============ 短链展开结果缓存 ============
# 用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀) # 用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀)
@@ -57,15 +57,45 @@ REQ_LOG: list[dict[str, Any]] = []
# ============ 上游探测缓存 ============ # ============ 上游探测缓存 ============
# 带时间戳的缓存一律用 TypedDict 显式声明字段类型:无标注的 dict 字面量会被类型
# 检查器推断为「所有键的值类型取并集」(如 `{'v': None, 'ts': 0.0}` → float | None),
# 于是每个读写点都退化成联合类型,既丢失精确性也淹没真实缺陷。
class _TimedValue(TypedDict):
"""值 + 写入时刻的二元缓存。"""
v: str | None
ts: float
class _NickCache(TypedDict):
"""服务器账号昵称缓存。"""
nick: str | None
ts: float
class _VipCache(TypedDict):
"""全局 VIP 缓存:data 为 None 表示尚未探测(读侧需判空)。"""
data: dict[str, Any] | None
ts: float
ttl: NotRequired[int]
class _VipEntry(TypedDict):
"""已写入的 VIP 缓存条目——三个字段齐备,data 必定存在。"""
data: dict[str, Any]
ts: float
ttl: int
# 全局账号 MUSIC_U(5 分钟缓存;后台重新扫码后自动跟随新值) # 全局账号 MUSIC_U(5 分钟缓存;后台重新扫码后自动跟随新值)
GLOBAL_MUSIC_U_CACHE = {'v': None, 'ts': 0.0} GLOBAL_MUSIC_U_CACHE: _TimedValue = {'v': None, 'ts': 0.0}
# 服务器网易云账号昵称缓存(10 分钟):非管理员可见响应中遮蔽真实昵称 # 服务器网易云账号昵称缓存(10 分钟):非管理员可见响应中遮蔽真实昵称
SERVER_NICK_CACHE = {'nick': None, 'ts': 0.0} SERVER_NICK_CACHE: _NickCache = {'nick': None, 'ts': 0.0}
SERVER_NICK_LOCK = threading.Lock() # 刷新单飞锁,防并发重复打上游 SERVER_NICK_LOCK = threading.Lock() # 刷新单飞锁,防并发重复打上游
VIP_PROBE_LOCK = threading.Lock() # 音质探测单飞锁(有界等待) VIP_PROBE_LOCK = threading.Lock() # 音质探测单飞锁(有界等待)
# 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口, # 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口,
# 公网下账号栏高频刷新会连累服务器账号触发风控。 # 公网下账号栏高频刷新会连累服务器账号触发风控。
VIP_CACHE = {'data': None, 'ts': 0.0} VIP_CACHE: _VipCache = {'data': None, 'ts': 0.0}
VIP_USER_CACHE = {} # user 路径按 cookie 哈希的 5 分钟缓存 # user 路径按 cookie 哈希的 5 分钟缓存
VIP_USER_CACHE: dict[str, _VipEntry] = {}
+5 -2
View File
@@ -92,11 +92,14 @@ exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"]
venvPath = "." venvPath = "."
venv = ".venv" venv = ".venv"
pythonVersion = "3.12" pythonVersion = "3.12"
typeCheckingMode = "basic" typeCheckingMode = "standard"
reportMissingImports = true reportMissingImports = true
# standard 比 basic 多出「变量可能未绑定 / 实参类型不符 / 无意义重定义 / 未使用符号」
# 等**可执行缺陷**(basic 全部漏报,实测 standard 多报 7 处真实缺陷),
# 故作为门禁级别;strict 的 reportUnknown* 需要给全部第三方库补存根,暂不启用。
# 以下三项在 standard 下已由类型系统覆盖,降为 warning 以避免第三方存根不全的噪声
reportOptionalMemberAccess = "warning" reportOptionalMemberAccess = "warning"
reportAttributeAccessIssue = "warning" reportAttributeAccessIssue = "warning"
reportGeneralTypeIssues = "warning"
# ============ Pytest ============ # ============ Pytest ============
[tool.pytest.ini_options] [tool.pytest.ini_options]
+4 -1
View File
@@ -46,6 +46,9 @@ def _batch_zip_worker(task_id: str) -> None:
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
zip_tmp_path: str | None = None zip_tmp_path: str | None = None
# zip_valid 必须在函数头初始化:ZIP 构建前的业务错误(参数缺失/无歌曲等)
# 会 early raise,异常处理器若引用未绑定名会 NameError 二次崩溃 worker 线程
zip_valid = False
ok_count = 0 ok_count = 0
failed: list[str] = [] failed: list[str] = []
used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖) used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖)
@@ -133,7 +136,7 @@ def _batch_zip_worker(task_id: str) -> None:
# 排队超 35 分钟即出现"扣费后领取 404"的悬空交付),根目录从此碰不到交付物 # 排队超 35 分钟即出现"扣费后领取 404"的悬空交付),根目录从此碰不到交付物
_task_dir_for_zip = api_service.downloads_path / 'tasks' / task_id _task_dir_for_zip = api_service.downloads_path / 'tasks' / task_id
_task_dir_for_zip.mkdir(parents=True, exist_ok=True) _task_dir_for_zip.mkdir(parents=True, exist_ok=True)
zip_valid = False # F-B:ZIP 中央目录完整写出才置 True,否则一律按失败全额退费 # 中央目录完整写出后(下方)才置 True,否则一律按失败全额退费
with _tempfile.NamedTemporaryFile(prefix='batch_zip_', suffix='.zip', with _tempfile.NamedTemporaryFile(prefix='batch_zip_', suffix='.zip',
dir=str(_task_dir_for_zip), dir=str(_task_dir_for_zip),
delete=False) as zip_tmp: delete=False) as zip_tmp:
+2 -1
View File
@@ -71,7 +71,8 @@ def _global_music_u() -> str:
except Exception: except Exception:
_GLOBAL_MUSIC_U_CACHE['v'] = '' _GLOBAL_MUSIC_U_CACHE['v'] = ''
_GLOBAL_MUSIC_U_CACHE['ts'] = now _GLOBAL_MUSIC_U_CACHE['ts'] = now
return _GLOBAL_MUSIC_U_CACHE['v'] # 契约返回 str:缓存未命中且读取失败时 v 会是 None,统一收敛为空串
return _GLOBAL_MUSIC_U_CACHE['v'] or ''
def _vip_name(vip_type: int) -> str: def _vip_name(vip_type: int) -> str:
+3 -1
View File
@@ -79,6 +79,7 @@ def _podcast_stream_download(url: str, target: Path, total_size: int,
now = time.time() now = time.time()
if now - last_update > 0.5: if now - last_update > 0.5:
last_update = now last_update = now
pct = 0
if total: if total:
pct = min(99, int(done / total * 100)) pct = min(99, int(done / total * 100))
msg = f"{progress_label} {pct}% · {api_service._format_file_size(done)}/{api_service._format_file_size(total)}" msg = f"{progress_label} {pct}% · {api_service._format_file_size(done)}/{api_service._format_file_size(total)}"
@@ -293,9 +294,10 @@ def _podcast_zip_worker(task_id: str) -> None:
zip_tmp_path: str | None = None zip_tmp_path: str | None = None
ok_count = 0 ok_count = 0
failed: list[str] = [] failed: list[str] = []
# zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等 # zip_valid / radio_name 必须在函数头初始化——空电台/无单集/补扣失败等
# 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程 # 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程
zip_valid = False zip_valid = False
radio_name = ''
try: try:
with DOWNLOAD_TASKS_LOCK: with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id] task = DOWNLOAD_TASKS[task_id]
+1 -1
View File
@@ -142,7 +142,7 @@ def _send_code_ip_check(ip: str) -> bool:
"""返回 True 表示允许发送;超限返回 False(表超限时顺手清理过期项,防无界增长)""" """返回 True 表示允许发送;超限返回 False(表超限时顺手清理过期项,防无界增长)"""
now = time.time() now = time.time()
if len(_SEND_CODE_IP) > 10000: if len(_SEND_CODE_IP) > 10000:
stale = [k for k, (c, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW] stale = [k for k, (_, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW]
for k in stale: for k in stale:
_SEND_CODE_IP.pop(k, None) _SEND_CODE_IP.pop(k, None)
cnt, win = _SEND_CODE_IP.get(ip, (0, now)) cnt, win = _SEND_CODE_IP.get(ip, (0, now))
+3
View File
@@ -379,6 +379,9 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use
limit = max(1, min(_safe_int(data.get('limit', 200) or 200, 200), 500)) limit = max(1, min(_safe_int(data.get('limit', 200) or 200, 200), 500))
quality = _podcast_quality(data) quality = _podcast_quality(data)
cookies = _cookies() cookies = _cookies()
# id_list 只在「按单集 ID 列表」分支赋值,但下面组装任务参数时两个分支都会
# 引用它(radio_id 为假则取 id_list)——先绑定,避免异常/类型检查下的未绑定名
id_list: list[str] = []
import podcast_api import podcast_api
if raw_radio: if raw_radio: