diff --git a/core/aiohttp_compat.py b/core/aiohttp_compat.py deleted file mode 100644 index f783887..0000000 --- a/core/aiohttp_compat.py +++ /dev/null @@ -1,142 +0,0 @@ -# -*- coding: utf-8 -*- -"""requests 兼容的异步 HTTP 垫片(基于 aiohttp)。 - -目的:让既有大量 `requests.post/get + response.json()/raise_for_status()` 风格的 -代码**以最小改动**迁移到异步——把 `import requests` 换成 -`from core import aiohttp_compat as requests`,加 `await`、加 `async def` 即可, -其余 `.text/.json()/.status_code/.raise_for_status()` 语义保持不变。 - -与 requests 的语义对齐点(很关键,不得改动): -- 请求本身**不**因 4xx/5xx 抛异常;只有显式 `raise_for_status()` 才抛 `HTTPError` - (上游风控 429/460 的重试/不重试判断依赖调用方读取 `status_code`,见 music_api.HTTPClient)。 -- 网络异常/超时抛 `RequestException`(含 `Timeout`)。 -- `Response` 一次性读全 body(与 requests 一致),暴露 text/json/content/headers/cookies/url。 - -连接按事件循环隔离(见 core.http_client 的同样设计)。 -""" -import asyncio -import json as _json -from collections.abc import Mapping -from typing import Any - -import aiohttp - -from core.http_errors import ConnectionError, HTTPError, RequestException, Timeout - -DEFAULT_TIMEOUT = 30 -DEFAULT_MAX_BYTES = 8 * 1024 * 1024 - - -class Response: - """requests.Response 的异步子集:body 已读全,属性同步访问。""" - - def __init__(self, status_code: int, url: str, content: bytes, - headers: Mapping[str, str], cookies: Mapping[str, str], reason: str = ''): - self.status_code = status_code - self.url = url - self.content = content - self.headers = dict(headers) - self.cookies = dict(cookies) - self.reason = reason - - @property - def text(self) -> str: - return self.content.decode('utf-8', errors='replace') - - def json(self, **kw) -> Any: - return _json.loads(self.text, **kw) - - def raise_for_status(self) -> None: - if 400 <= self.status_code: - raise HTTPError(f"{self.status_code} {self.reason}".strip(), self) - - -# ---------- 会话(按 loop 隔离的 aiohttp session) ---------- - - -class _SessionState: - def __init__(self): - self._sessions: dict[int, aiohttp.ClientSession] = {} - self._lock: asyncio.Lock | None = None - - def _get_lock(self) -> asyncio.Lock: - if self._lock is None: - self._lock = asyncio.Lock() - return self._lock - - async def session(self) -> aiohttp.ClientSession: - key = id(asyncio.get_running_loop()) - sess = self._sessions.get(key) - if sess is not None and not sess.closed: - return sess - async with self._get_lock(): - sess = self._sessions.get(key) - if sess is None or sess.closed: - sess = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(limit=100, limit_per_host=32, ttl_dns_cache=300), - ) - self._sessions[key] = sess - return sess - - async def close_all(self) -> None: - for sess in self._sessions.values(): - if not sess.closed: - await sess.close() - self._sessions.clear() - - -_state = _SessionState() - - -async def close() -> None: - """关闭所有连接(应用关闭时调用)。""" - await _state.close_all() - - -async def request( - method: str, - url: str, - *, - params: Any = None, - data: Any = None, - json: Any = None, - headers: Mapping[str, str] | None = None, - cookies: Mapping[str, str] | None = None, - timeout: float | None = DEFAULT_TIMEOUT, - allow_redirects: bool = True, - max_bytes: int = DEFAULT_MAX_BYTES, -) -> Response: - """发起单次异步请求,返回 Response(不因 4xx/5xx 抛异常,同 requests)。""" - sess = await _state.session() - req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None - try: - async with sess.request( - method, url, - params=params, data=data, json=json, - headers=dict(headers) if headers else None, - cookies=dict(cookies) if cookies else None, - timeout=req_timeout, allow_redirects=allow_redirects, - ) as resp: - body = await resp.read() - if len(body) > max_bytes: - raise RequestException(f"响应体超过上限 {max_bytes} 字节") - return Response( - status_code=resp.status, - url=str(resp.url), - content=body, - headers=dict(resp.headers), - cookies={k: v.value for k, v in resp.cookies.items()}, - reason=resp.reason or '', - ) - except aiohttp.ClientError as e: - raise ConnectionError(str(e)) from e - except TimeoutError as e: - raise Timeout('请求超时') from e - - -async def get(url: str, **kw) -> Response: - return await request('GET', url, **kw) - - -async def post(url: str, **kw) -> Response: - return await request('POST', url, **kw) diff --git a/core/http_client.py b/core/http_client.py index 6acd5e0..eb32166 100644 --- a/core/http_client.py +++ b/core/http_client.py @@ -143,7 +143,7 @@ class AsyncHTTPClient: # ---------- 底层:单次请求(不重试) ---------- - async def _once( + async def request_once( self, method: str, url: str, @@ -156,6 +156,11 @@ class AsyncHTTPClient: timeout: float | None = None, allow_redirects: bool = True, ) -> HTTPResult: + """发起一次请求,**不做重试**(自管重试策略的调用方用这个)。 + + 非 2xx 一律抛 UpstreamHTTPError(含 5xx)——调用方按 .status 自行决定是否重试。 + request() 在此基础上叠加「网络异常/5xx 退避重试一次」。 + """ session = await self._session_for_loop() req_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None try: @@ -211,7 +216,7 @@ class AsyncHTTPClient: # ---------- 高层 API ---------- async def request(self, method: str, url: str, **kw) -> HTTPResult: - return await self._with_retry(lambda: self._once(method, url, **kw)) + return await self._with_retry(lambda: self.request_once(method, url, **kw)) async def get_text(self, url: str, **kw) -> str: return (await self.request('GET', url, **kw)).text diff --git a/core/http_errors.py b/core/http_errors.py index 74ae34d..67710ee 100644 --- a/core/http_errors.py +++ b/core/http_errors.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- -"""requests 同形异常层次(同步 / 异步两个 HTTP 垫片共用)。 +"""requests 同形异常层次(同步 HTTP 门面 core.sync_http 使用)。 -`core.aiohttp_compat`(异步)与 `core.sync_http`(同步)此前各自定义了一份 -`RequestException` / `HTTPError`——两份定义互不相干,于是同一条调用链上 -「同步段抛的异常捕获不到异步段的同类失败」,调用方被迫记住自己走的是哪条垫片。 +历史:`core.aiohttp_compat`(异步垫片)与 `core.sync_http`(同步门面)曾各自 +定义一份 `RequestException` / `HTTPError`——两份定义互不相干,于是同一条调用链上 +「同步段抛的异常捕获不到异步段的同类失败」。异步垫片已删除、其调用方(music_api) +全面改走 core.http_client(自带 UpstreamError 层次);此处保留唯一来源, +供仍在线程里同步取数据的 core.sync_http 使用。 -此处收敛为唯一来源;命名沿用 requests(`Timeout` / `ConnectionError` 与内置 -异常同名,这是 requests 自身的约定——调用方应带模块前缀引用,例如 -`requests.ConnectionError`,裸 `ConnectionError` 会命中内置的那个)。 +命名沿用 requests(`Timeout` / `ConnectionError` 与内置异常同名,这是 requests +自身的约定——调用方应带模块前缀引用,例如 `requests.ConnectionError`, +裸 `ConnectionError` 会命中内置的那个)。 层次: RequestException 网络 / 协议层失败(可捕获的总入口) diff --git a/core/sync_http.py b/core/sync_http.py index ae050ed..652b0ba 100644 --- a/core/sync_http.py +++ b/core/sync_http.py @@ -18,9 +18,9 @@ from core import async_bridge from core.http_client import client as _client from core.http_errors import HTTPError, RequestException -# 异常层次与异步垫片(core.aiohttp_compat)共用同一份定义——调用方可用同一组 -# 类型捕获两条通道的失败。RequestException 在此再导出:music_downloader.models -# 的 _RetryFromScratch 继承它,既有 except 与测试也直接导入它。 +# 异常层次见 core.http_errors(与调用方共用的 requests 同形定义)。RequestException +# 在此再导出:music_downloader.models 的 _RetryFromScratch 继承它,既有 except +# 与测试也直接导入它。 __all__ = ['HTTPError', 'RequestException', 'Session', 'SyncResponse', 'get', 'post'] _DEFAULT_TIMEOUT = 30 diff --git a/music_api/__init__.py b/music_api/__init__.py index 3d9a3d0..f4cc9ce 100644 --- a/music_api/__init__.py +++ b/music_api/__init__.py @@ -18,7 +18,6 @@ from .api import ( NeteaseAPI, ) from .client import ( - _MAX_UPSTREAM_JSON_BYTES, HTTPClient, _as_dict, _ensure_dict, @@ -48,7 +47,6 @@ from .qr import ( __all__ = [ 'DEFAULT_COOKIE_FILE', - '_MAX_UPSTREAM_JSON_BYTES', 'APIConstants', 'APIException', 'CryptoUtils', diff --git a/music_api/api.py b/music_api/api.py index 8e740cc..5106a20 100644 --- a/music_api/api.py +++ b/music_api/api.py @@ -10,7 +10,8 @@ from collections.abc import Callable from random import randrange from typing import Any -from core import aiohttp_compat as requests # 异步 HTTP 垫片(requests 同形 API) +from core.http_client import UpstreamError +from core.http_client import client as _http from .client import ( HTTPClient, @@ -71,9 +72,8 @@ async def _collect_playlist_tracks(track_ids: list[str], headers: dict[str, str] except (TypeError, ValueError): continue - song_resp = await requests.post(APIConstants.SONG_DETAIL_V3, data=song_data, + song_resp = await _http.request('POST', APIConstants.SONG_DETAIL_V3, data=song_data, headers=headers, cookies=cookies, timeout=30) - song_resp.raise_for_status() song_result = song_resp.json() if not isinstance(song_result, dict): @@ -222,9 +222,8 @@ class NeteaseAPI: 'User-Agent': APIConstants.USER_AGENT, 'Referer': APIConstants.REFERER } - response = await requests.post(APIConstants.SONG_DETAIL_V3, data=data, + response = await _http.request('POST', APIConstants.SONG_DETAIL_V3, data=data, headers=headers, timeout=30) - response.raise_for_status() result = response.json() # 合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真 @@ -234,7 +233,7 @@ class NeteaseAPI: raise APIException(f"获取歌曲详情失败: {result.get('message', '未知错误')}") return result - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"获取歌曲详情请求失败: {e}") from e except json.JSONDecodeError as e: raise APIException(f"解析歌曲详情响应失败: {e}") from e @@ -270,9 +269,8 @@ class NeteaseAPI: 'Referer': APIConstants.REFERER } - response = await requests.post(APIConstants.LYRIC_API, data=data, + response = await _http.request('POST', APIConstants.LYRIC_API, data=data, headers=headers, cookies=cookies, timeout=30) - response.raise_for_status() result = response.json() _ensure_dict(result, 'get_lyric 接口响应异常(非标准 JSON 对象)') @@ -280,7 +278,7 @@ class NeteaseAPI: raise APIException(f"获取歌词失败: {result.get('message', '未知错误')}") return result - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"获取歌词请求失败: {e}") from e except json.JSONDecodeError as e: raise APIException(f"解析歌词响应失败: {e}") from e @@ -310,9 +308,8 @@ class NeteaseAPI: 'Referer': APIConstants.REFERER } - response = await requests.post(APIConstants.SEARCH_API, data=data, + response = await _http.request('POST', APIConstants.SEARCH_API, data=data, headers=headers, cookies=cookies, timeout=30) - response.raise_for_status() result = response.json() # 非 dict 响应(null/数组)给明确错误 @@ -323,7 +320,7 @@ class NeteaseAPI: result_data = result.get('result') or {} return _collect_search_items(result_data, search_type) - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"搜索请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析搜索响应失败: {e}") from e @@ -348,9 +345,8 @@ class NeteaseAPI: 'Referer': APIConstants.REFERER } - response = await requests.post(APIConstants.PLAYLIST_DETAIL_API, data=data, + response = await _http.request('POST', APIConstants.PLAYLIST_DETAIL_API, data=data, headers=headers, cookies=cookies, timeout=30) - response.raise_for_status() result = response.json() # 非 dict 响应(null/数组)给明确错误 @@ -384,7 +380,7 @@ class NeteaseAPI: info['failed_batches'] = failed_batches return info - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"获取歌单详情请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析歌单详情响应失败: {e}") from e @@ -409,8 +405,7 @@ class NeteaseAPI: 'Referer': APIConstants.REFERER } - response = await requests.get(url, headers=headers, cookies=cookies, timeout=30) - response.raise_for_status() + response = await _http.request('GET', url, headers=headers, cookies=cookies, timeout=30) result = response.json() _ensure_dict(result, 'get_album_detail 接口响应异常(非标准 JSON 对象)') @@ -440,7 +435,7 @@ class NeteaseAPI: }) return info - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"获取专辑详情请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析专辑详情响应失败: {e}") from e @@ -460,8 +455,7 @@ class NeteaseAPI: 'Referer': APIConstants.REFERER } - response = await requests.get(APIConstants.TOPLIST_API, headers=headers, timeout=30) - response.raise_for_status() + response = await _http.request('GET', APIConstants.TOPLIST_API, headers=headers, timeout=30) result = response.json() _ensure_dict(result, 'get_toplist 接口响应异常(非标准 JSON 对象)') @@ -481,7 +475,7 @@ class NeteaseAPI: 'topTitle': item.get('ToplistType', '') }) return toplists - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"获取榜单请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析榜单响应失败: {e}") from e @@ -505,8 +499,7 @@ class NeteaseAPI: 'Referer': APIConstants.REFERER } - response = await requests.get(url, headers=headers, timeout=30) - response.raise_for_status() + response = await _http.request('GET', url, headers=headers, timeout=30) result = response.json() _ensure_dict(result, 'get_artist_hot_songs 接口响应异常(非标准 JSON 对象)') @@ -535,7 +528,7 @@ class NeteaseAPI: }, 'songs': songs } - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"获取歌手信息请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析歌手信息响应失败: {e}") from e @@ -561,8 +554,7 @@ class NeteaseAPI: 'Referer': APIConstants.REFERER } - response = await requests.get(url, headers=headers, timeout=30) - response.raise_for_status() + response = await _http.request('GET', url, headers=headers, timeout=30) result = response.json() _ensure_dict(result, 'get_song_comments 接口响应异常(非标准 JSON 对象)') @@ -602,7 +594,7 @@ class NeteaseAPI: 'hotComments': [_comment_dict(c) for c in (result.get('hotComments') or [])], 'comments': [_comment_dict(c) for c in (result.get('comments') or [])], } - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"获取评论请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析评论响应失败: {e}") from e @@ -622,9 +614,8 @@ class NeteaseAPI: 'Referer': APIConstants.REFERER } - response = await requests.get(APIConstants.USER_ACCOUNT_API, headers=headers, + response = await _http.request('GET', APIConstants.USER_ACCOUNT_API, headers=headers, cookies=cookies, timeout=30) - response.raise_for_status() result = response.json() _ensure_dict(result, 'get_account_info 接口响应异常(非标准 JSON 对象)') @@ -674,9 +665,8 @@ class NeteaseAPI: page_size = 100 for _ in range(20): # 有界翻页,防上游 more 异常导致无限循环 params = {'uid': user_id, 'limit': page_size, 'offset': offset} - response = await requests.get(APIConstants.USER_PLAYLIST_API, params=params, + response = await _http.request('GET', APIConstants.USER_PLAYLIST_API, params=params, headers=headers, cookies=cookies, timeout=30) - response.raise_for_status() result = response.json() _ensure_dict(result, 'get_user_playlists 接口响应异常(非标准 JSON 对象)') @@ -706,7 +696,7 @@ class NeteaseAPI: break offset += page_size return playlists - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"获取用户歌单请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析用户歌单响应失败: {e}") from e @@ -790,15 +780,14 @@ class NeteaseAPI: 'User-Agent': APIConstants.USER_AGENT, 'Referer': APIConstants.REFERER } - response = await requests.post(APIConstants.PLAYLIST_SUBSCRIBE_API, data=data, + response = await _http.request('POST', APIConstants.PLAYLIST_SUBSCRIBE_API, data=data, headers=headers, cookies=cookies, timeout=30) - response.raise_for_status() result = response.json() _ensure_dict(result, 'subscribe_playlist 接口响应异常(非标准 JSON 对象)') if result.get('code') != 200: raise APIException(f"收藏歌单失败: {result.get('message', '未知错误')}") return result - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"收藏歌单请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析收藏歌单响应失败: {e}") from e @@ -824,15 +813,14 @@ class NeteaseAPI: 'User-Agent': APIConstants.USER_AGENT, 'Referer': APIConstants.REFERER } - response = await requests.post(APIConstants.ALBUM_SUBSCRIBE_API, data=data, + response = await _http.request('POST', APIConstants.ALBUM_SUBSCRIBE_API, data=data, headers=headers, cookies=cookies, timeout=30) - response.raise_for_status() result = response.json() _ensure_dict(result, 'subscribe_album 接口响应异常(非标准 JSON 对象)') if result.get('code') != 200: raise APIException(f"收藏专辑失败: {result.get('message', '未知错误')}") return result - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"收藏专辑请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析收藏专辑响应失败: {e}") from e @@ -875,9 +863,8 @@ class NeteaseAPI: 'User-Agent': APIConstants.USER_AGENT, 'Referer': APIConstants.REFERER, } - response = await requests.post(APIConstants.COMMENT_POST_API, data=body, + response = await _http.request('POST', APIConstants.COMMENT_POST_API, data=body, headers=headers, cookies=cookies or {}, timeout=30) - response.raise_for_status() text = response.text.strip() if not text: @@ -889,7 +876,7 @@ class NeteaseAPI: if result.get('code') != 200: raise APIException(f"评论失败: {result.get('message', '未知错误')}") return result - except requests.RequestException as e: + except UpstreamError as e: raise APIException(f"评论请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: raise APIException(f"解析评论响应失败: {e}") from e diff --git a/music_api/client.py b/music_api/client.py index 40695be..cbe3495 100644 --- a/music_api/client.py +++ b/music_api/client.py @@ -7,7 +7,13 @@ import asyncio from typing import Any -from core import aiohttp_compat as requests # 异步 HTTP 垫片(requests 同形 API) +from core.http_client import ( + HTTPResult, + UpstreamError, + UpstreamHTTPError, + UpstreamTooLarge, +) +from core.http_client import client as _http from .constants import ( APIConstants, @@ -41,7 +47,12 @@ class HTTPClient: async def post_request(url: str, params: str, cookies: dict[str, str]) -> str: """发送POST请求并返回文本响应。 网络异常/5xx 做一次 1 秒退避重试(全站 API 调用此前零重试, - 上游抖动即整首失败);4xx(429/460 风控)不重试——立即重试会加剧风控。""" + 上游抖动即整首失败);4xx(429/460 风控)不重试——立即重试会加剧风控。 + + 走 core.http_client.request_once(单次、不重试):重试策略由本函数自管, + 非 2xx 由 request_once 抛 UpstreamHTTPError,按 .status 分流—— + 与原先「垫片只在 raise_for_status() 时才抛 HTTPError」的分流等价。 + """ headers = { 'User-Agent': APIConstants.USER_AGENT, 'Referer': APIConstants.REFERER, @@ -53,20 +64,21 @@ class HTTPClient: last_err = None for attempt in range(2): try: - response = await requests.post(url, headers=headers, cookies=request_cookies, - data={"params": params}, timeout=30) - if response.status_code >= 500 and attempt == 0: + response = await _http.request_once( + 'POST', url, headers=headers, cookies=request_cookies, + data={"params": params}, timeout=30) + return response.text + except UpstreamHTTPError as e: + if e.status >= 500 and attempt == 0: await asyncio.sleep(1.0) continue - response.raise_for_status() - if len(response.content) > _MAX_UPSTREAM_JSON_BYTES: - raise APIException("上游响应体异常(超过大小上限)") - return response.text - except requests.HTTPError as e: # 4xx(429/460 风控)不重试——原实现 HTTPError 落入 - # 下方 RequestException 分支被重试一次,与 意图相反 + # 下方异常分支被重试一次,与意图相反 raise APIException(f"HTTP请求失败: {e}") from e - except requests.RequestException as e: + except UpstreamTooLarge as e: + # 响应体超限是确定性失败,重试无意义 + raise APIException("上游响应体异常(超过大小上限)") from e + except UpstreamError as e: last_err = e if attempt == 0: await asyncio.sleep(1.0) @@ -74,7 +86,7 @@ class HTTPClient: raise APIException(f"HTTP请求失败: {last_err}") @staticmethod - async def post_request_full(url: str, params: str, cookies: dict[str, str]) -> requests.Response: + async def post_request_full(url: str, params: str, cookies: dict[str, str]) -> HTTPResult: """发送POST请求并返回完整响应对象(同 post_request 的有界重试)""" headers = { 'User-Agent': APIConstants.USER_AGENT, @@ -87,26 +99,21 @@ class HTTPClient: last_err = None for attempt in range(2): try: - response = await requests.post(url, headers=headers, cookies=request_cookies, - data={"params": params}, timeout=30) - if response.status_code >= 500 and attempt == 0: + response = await _http.request_once( + 'POST', url, headers=headers, cookies=request_cookies, + data={"params": params}, timeout=30) + return response + except UpstreamHTTPError as e: + if e.status >= 500 and attempt == 0: await asyncio.sleep(1.0) continue - response.raise_for_status() - if len(response.content) > _MAX_UPSTREAM_JSON_BYTES: - raise APIException("上游响应体异常(超过大小上限)") - return response - except requests.HTTPError as e: # 4xx 不重试(同上) raise APIException(f"HTTP请求失败: {e}") from e - except requests.RequestException as e: + except UpstreamTooLarge as e: + raise APIException("上游响应体异常(超过大小上限)") from e + except UpstreamError as e: last_err = e if attempt == 0: await asyncio.sleep(1.0) continue raise APIException(f"HTTP请求失败: {last_err}") - - -# 上游 JSON 响应体上限——requests 已全量入内存,此处拦截的是 -# 异常/被劫持上游的超大 body 进入后续 .json() 解析与传播(音频/封面已有字节预算) -_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024 diff --git a/pyproject.toml b/pyproject.toml index 4f06a2a..b4c561b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,6 @@ ignore = [ # ASYNC109:timeout 参数是有意 API——直接转发给 aiohttp 的 ClientTimeout, # 不是「用 sleep 当超时」的反模式。 "core/http_client.py" = ["ASYNC109"] -"core/aiohttp_compat.py" = ["ASYNC109"] [tool.ruff.lint.isort] known-first-party = ["core", "web", "services", "music_api", "auth_system", "music_downloader"] diff --git a/tests/test_aiohttp_compat.py b/tests/test_aiohttp_compat.py deleted file mode 100644 index 4eae370..0000000 --- a/tests/test_aiohttp_compat.py +++ /dev/null @@ -1,86 +0,0 @@ -# -*- coding: utf-8 -*- -"""兼容垫片与同步桥测试。""" -import asyncio - -import pytest -from aiohttp import web - -from core import aiohttp_compat as req -from core import async_bridge - - -@pytest.fixture() -async def server(): - app = web.Application() - - async def ok(request): - return web.json_response({'code': 200, 'data': 'hi'}) - - async def notfound(request): - return web.json_response({'code': 404}, status=404) - - async def echo_post(request): - body = await request.text() - return web.json_response({'got': body, 'ua': request.headers.get('User-Agent')}) - - app.router.add_get('/ok', ok) - app.router.add_get('/nf', notfound) - app.router.add_post('/echo', echo_post) - - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, '127.0.0.1', 0) - await site.start() - port = runner.addresses[0][1] - try: - yield f'http://127.0.0.1:{port}' - finally: - await runner.cleanup() - - -async def test_get_json(server): - r = await req.get(server + '/ok') - assert r.status_code == 200 - assert r.json() == {'code': 200, 'data': 'hi'} - assert 'code' in r.text - r.raise_for_status() # 200 不抛 - - -async def test_4xx_does_not_raise_until_raise_for_status(server): - r = await req.get(server + '/nf') - assert r.status_code == 404 # 请求本身不抛(同 requests) - assert r.json() == {'code': 404} - with pytest.raises(req.HTTPError): - r.raise_for_status() - - -async def test_post_headers_forwarded(server): - r = await req.post(server + '/echo', data={'params': 'x'}, headers={'User-Agent': 'ua-test'}) - body = r.json() - assert body['ua'] == 'ua-test' - assert 'params' in body['got'] - - -async def test_network_error_raises_request_exception(): - with pytest.raises(req.RequestException): - await req.get('http://127.0.0.1:1/nope', timeout=1) - - -def test_bridge_runs_coro_from_sync(): - async def coro(): - await asyncio.sleep(0.01) - return 42 - - assert async_bridge.run_sync(coro()) == 42 - - -async def test_bridge_rejects_inside_loop(): - async def coro(): - return 1 - - c = coro() - try: - with pytest.raises(RuntimeError): - async_bridge.run_sync(c) - finally: - c.close() # run_sync 拒绝执行,需自行关闭以免「未 await」告警 diff --git a/tests/test_async_bridge.py b/tests/test_async_bridge.py new file mode 100644 index 0000000..72bdbb0 --- /dev/null +++ b/tests/test_async_bridge.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +"""同步 ↔ 异步桥测试。 + +(原 test_aiohttp_compat.py:异步 HTTP 垫片 core.aiohttp_compat 已随其调用方 +music_api 全面改走 core.http_client 而删除,仅保留桥本身的行为契约。) +""" +import asyncio + +import pytest + +from core import async_bridge + + +def test_bridge_runs_coro_from_sync(): + async def coro(): + await asyncio.sleep(0.01) + return 42 + + assert async_bridge.run_sync(coro()) == 42 + + +async def test_bridge_rejects_inside_loop(): + async def coro(): + return 1 + + c = coro() + try: + with pytest.raises(RuntimeError): + async_bridge.run_sync(c) + finally: + c.close() # run_sync 拒绝执行,需自行关闭以免「未 await」告警 diff --git a/tests/test_playlist_detail.py b/tests/test_playlist_detail.py index 65db6d6..46bcd02 100644 --- a/tests/test_playlist_detail.py +++ b/tests/test_playlist_detail.py @@ -9,14 +9,15 @@ - trackIds 数与实得数不一致时显式标 incomplete/missing 按惯例:先在未改动的原始实现上跑绿,重构时这批测试即等价性验证器。 -替身边界:monkeypatch ``music_api.api.requests.post``,按 URL 分流—— -PLAYLIST_DETAIL_API 与 SONG_DETAIL_V3 分别给响应替身。 +替身边界:monkeypatch ``music_api.api._http.request``(core.http_client 的进程级 +客户端),按 URL 分流——PLAYLIST_DETAIL_API 与 SONG_DETAIL_V3 分别给响应替身。 """ import json from typing import Any import pytest +from core.http_client import UpstreamNetworkError from music_api import APIException from music_api import api as api_mod from music_api.api import NeteaseAPI @@ -26,19 +27,13 @@ _API = NeteaseAPI() class _FakeResp: - """``requests.post`` 响应替身:json 载荷或注入异常。""" + """请求响应替身(``HTTPResult`` 的形状子集):json 载荷或注入异常。""" def __init__(self, payload: Any = None, *, - status_error: Exception | None = None, json_error: Exception | None = None): self.payload = payload - self.status_error = status_error self.json_error = json_error - def raise_for_status(self) -> None: - if self.status_error is not None: - raise self.status_error - def json(self) -> Any: if self.json_error is not None: raise self.json_error @@ -47,14 +42,14 @@ class _FakeResp: @pytest.fixture() def api_capture(monkeypatch): - """按 URL 分流的 requests.post 替身:歌单详情响应固定,批次响应按序消费。""" + """按 URL 分流的请求替身:歌单详情响应固定,批次响应按序消费。""" calls: list[dict[str, Any]] = [] state: dict[str, Any] = { 'playlist_resp': _FakeResp({'code': 200, 'playlist': {}}), 'batch_resps': [_FakeResp({'code': 200, 'songs': []})], } - async def _fake_post(url: str, **kw: Any): + async def _fake_request(method: str, url: str, **kw: Any): calls.append({'url': url, 'data': kw.get('data')}) if url == APIConstants.PLAYLIST_DETAIL_API: return state['playlist_resp'] @@ -63,7 +58,7 @@ def api_capture(monkeypatch): raise AssertionError('批次响应耗尽') return state['batch_resps'].pop(0) - monkeypatch.setattr(api_mod.requests, 'post', _fake_post) + monkeypatch.setattr(api_mod._http, 'request', _fake_request) state['calls'] = calls return state @@ -197,10 +192,10 @@ async def test_batches_of_100(api_capture): async def test_network_error_maps_to_request_failed(api_capture, monkeypatch): """网络层异常(含批次阶段的):统一转「获取歌单详情请求失败」。""" - async def _boom(url: str, **kw: Any): - raise api_mod.requests.RequestException('connection reset') + async def _boom(method: str, url: str, **kw: Any): + raise UpstreamNetworkError('connection reset') - monkeypatch.setattr(api_mod.requests, 'post', _boom) + monkeypatch.setattr(api_mod._http, 'request', _boom) with pytest.raises(APIException, match='获取歌单详情请求失败'): await _API.get_playlist_detail(123, {}) diff --git a/tests/test_search_music.py b/tests/test_search_music.py index d7267a8..ee07e92 100644 --- a/tests/test_search_music.py +++ b/tests/test_search_music.py @@ -8,15 +8,17 @@ 拆解时改坏任意一路都不会有测试报警。 按惯例:先在未改动的原始实现上跑绿,重构时这批测试即等价性验证器。 -替身边界:monkeypatch ``music_api.api.requests.post``(aiohttp_compat 垫片 -的同形入口),响应替身只实现 ``raise_for_status`` / ``json`` 两个同步方法—— -``search_music`` 只用到这两个。 +替身边界:monkeypatch ``music_api.api._http.request``(core.http_client 的进程级 +客户端),响应替身实现 ``json`` 一个同步方法——``search_music`` 只用到它。 +状态码错误不再由替身经 ``raise_for_status`` 模拟:请求入口自身即对非 2xx 抛 +``UpstreamHTTPError``(见 core.http_client.request_once)。 """ import json from typing import Any import pytest +from core.http_client import UpstreamNetworkError from music_api import APIException from music_api import api as api_mod from music_api.api import NeteaseAPI @@ -26,19 +28,13 @@ _API = NeteaseAPI() class _FakeResp: - """``requests.post`` 响应替身:json 载荷或注入异常。""" + """请求响应替身(``HTTPResult`` 的形状子集):json 载荷或注入异常。""" def __init__(self, payload: Any = None, *, - status_error: Exception | None = None, json_error: Exception | None = None): self.payload = payload - self.status_error = status_error self.json_error = json_error - def raise_for_status(self) -> None: - if self.status_error is not None: - raise self.status_error - def json(self) -> Any: if self.json_error is not None: raise self.json_error @@ -47,15 +43,16 @@ class _FakeResp: @pytest.fixture() def post_capture(monkeypatch): - """替换 requests.post,捕获调用参数并按用例注入响应。""" + """替换请求入口,捕获调用参数并按用例注入响应。""" holder: dict[str, Any] = {'resp': _FakeResp({'code': 200, 'result': {}})} - async def _fake_post(url: str, **kw: Any): + async def _fake_request(method: str, url: str, **kw: Any): + holder['method'] = method holder['url'] = url holder['data'] = kw.get('data') return holder['resp'] - monkeypatch.setattr(api_mod.requests, 'post', _fake_post) + monkeypatch.setattr(api_mod._http, 'request', _fake_request) return holder @@ -178,16 +175,15 @@ async def test_non_dict_response_raises_format_error(post_capture): async def test_request_error_maps_to_api_exception(post_capture, monkeypatch): - """网络层异常(垫片 RequestException 族):转"搜索请求失败"。 + """网络层异常(core.http_client 的 UpstreamError 族):转"搜索请求失败"。 - 注意替身抛的必须是垫片自己的 ``RequestException``——真实链路里 - aiohttp.ClientError 由 aiohttp_compat 内部翻译后才冒出来,直接抛底层 - 异常是不存在的形状(test_request_* 相关层次见 core.http_errors)。 + 注意替身抛的必须是 ``UpstreamNetworkError``——真实链路里 aiohttp.ClientError + 由 core.http_client 内部翻译后才冒出来,直接抛底层异常是不存在的形状。 """ - async def _boom(url: str, **kw: Any): - raise api_mod.requests.RequestException('connection reset') + async def _boom(method: str, url: str, **kw: Any): + raise UpstreamNetworkError('connection reset') - monkeypatch.setattr(api_mod.requests, 'post', _boom) + monkeypatch.setattr(api_mod._http, 'request', _boom) with pytest.raises(APIException, match='搜索请求失败'): await _API.search_music('测试', {}, search_type=1) diff --git a/web/app.py b/web/app.py index 6031903..14352e7 100644 --- a/web/app.py +++ b/web/app.py @@ -62,10 +62,9 @@ def _make_lifespan(start_monitor: bool): await asyncio.to_thread(start_task_monitor) yield try: - from core import aiohttp_compat, async_bridge, http_client + from core import async_bridge, http_client await http_client.client.close() - await aiohttp_compat.close() # 停掉 sync↔async 桥的后台事件循环(daemon 线程不调也能随进程退出, # 但显式关闭更干净) async_bridge.shutdown()