Archived
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:
1 parent
fc7fc21284
commit
183f5744d8
9 files changed
+58
-16
No files matched your search
+3
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user