diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 171b309b..ae0f3ef3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,8 +6,7 @@ requires-python = ">=3.14" dependencies = [ "fastapi>=0.109.0", "uvicorn>=0.27.0", - "httpx[socks]>=0.25.0", - "httpx-socks>=0.9.0", + "httpx2[socks]>=2.15", "beautifulsoup4>=4.15.0", "sqlmodel>=0.0.48", "sqlalchemy[aiosqlite]>=2.1.5", @@ -79,11 +78,6 @@ disallow_untyped_defs = false module = "module.__version__" ignore_missing_imports = true -# httpx_socks 已安装但未发布类型存根 -[[tool.mypy.overrides]] -module = "httpx_socks.*" -ignore_missing_imports = true - # aiohttp 仅用于 e2e mock server(在独立 Docker 容器中安装,不在本项目依赖中) [[tool.mypy.overrides]] module = "aiohttp" diff --git a/backend/sdk/pyproject.toml b/backend/sdk/pyproject.toml index 26f40bd3..a2655d8e 100644 --- a/backend/sdk/pyproject.toml +++ b/backend/sdk/pyproject.toml @@ -13,7 +13,7 @@ requires-python = ">=3.14" license = "MIT" dependencies = [ "pydantic>=2.0.0", - "httpx>=0.25.0", + "httpx2>=2.15", "packaging>=26.0", ] diff --git a/backend/src/ab_sdk/__init__.py b/backend/src/ab_sdk/__init__.py index 9dca6562..469453f0 100644 --- a/backend/src/ab_sdk/__init__.py +++ b/backend/src/ab_sdk/__init__.py @@ -12,7 +12,7 @@ from .events import Event, PluginDisabled, PluginLoaded from .hooks import Verdict, hook, provider, subscribe from .plugin import Plugin -SDK_VERSION = "0.5.0" +SDK_VERSION = "0.6.0" __all__ = [ "SDK_VERSION", diff --git a/backend/src/ab_sdk/llm.py b/backend/src/ab_sdk/llm.py index 32c24340..ebd4e5bb 100644 --- a/backend/src/ab_sdk/llm.py +++ b/backend/src/ab_sdk/llm.py @@ -13,7 +13,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any, Callable, ClassVar, Literal, Optional -import httpx +import httpx2 from pydantic import BaseModel @@ -66,9 +66,9 @@ class AdapterContext: api_key: str = "" base_url: str = "" timeout: float = 20.0 - # llm._build_http_client——代理感知的 httpx 客户端工厂(保持其 + # llm._build_http_client——代理感知的 httpx2 客户端工厂(保持其 # patch 路径在 llm.py,因此以依赖注入方式传入)。 - build_http_client: Callable[[float], httpx.AsyncClient] = None # type: ignore[assignment] + build_http_client: Callable[[float], httpx2.AsyncClient] = None # type: ignore[assignment] # M3 起为 CredentialStore;api_key 类适配器恒为 None。 credentials: Any = None @@ -78,7 +78,7 @@ class LLMProviderAdapter(ABC): info: ClassVar[ProviderInfo] - _http_client: Optional[httpx.AsyncClient] = None + _http_client: Optional[httpx2.AsyncClient] = None def __init__(self, ctx: AdapterContext) -> None: self.ctx = ctx diff --git a/backend/src/module/ab_decorator/__init__.py b/backend/src/module/ab_decorator/__init__.py index 689e7850..8cf8cb83 100644 --- a/backend/src/module/ab_decorator/__init__.py +++ b/backend/src/module/ab_decorator/__init__.py @@ -2,7 +2,7 @@ import asyncio import functools import logging -import httpx +import httpx2 logger = logging.getLogger(__name__) _lock = asyncio.Lock() @@ -22,9 +22,9 @@ def qb_connect_failed_wait(func): ConnectionError, TimeoutError, OSError, - httpx.ConnectError, - httpx.TimeoutException, - httpx.RequestError, + httpx2.ConnectError, + httpx2.TimeoutException, + httpx2.RequestError, ) as e: last_error = e delay = _RETRY_DELAYS[times] diff --git a/backend/src/module/api/setup.py b/backend/src/module/api/setup.py index 6ba20386..695a334d 100644 --- a/backend/src/module/api/setup.py +++ b/backend/src/module/api/setup.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Annotated from urllib.parse import urlparse -import httpx +import httpx2 from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, Field @@ -213,7 +213,7 @@ async def test_downloader(req: TestDownloaderRequest): _validate_scheme(host) try: - async with httpx.AsyncClient(timeout=5.0) as client: + async with httpx2.AsyncClient(timeout=5.0) as client: if req.type == "aria2": # aria2 走 JSON-RPC:getVersion 一次验证可达性与 RPC secret params = [f"token:{req.password}"] if req.password else [] @@ -288,13 +288,13 @@ async def test_downloader(req: TestDownloaderRequest): message_en="Authentication failed: incorrect username or password.", message_zh="认证失败:用户名或密码错误。", ) - except httpx.TimeoutException: + except httpx2.TimeoutException: return TestResultResponse( success=False, message_en="Connection timed out.", message_zh="连接超时。", ) - except httpx.ConnectError: + except httpx2.ConnectError: return TestResultResponse( success=False, message_en="Cannot connect to the host.", @@ -438,7 +438,7 @@ async def complete_setup( # settings.save() does synchronous file I/O; keep it off the event loop. await asyncio.to_thread(settings.save, config_dict) - # Route through the AppContext reload path so the shared httpx client, + # Route through the AppContext reload path so the shared httpx2 client, # notifier, and scheduler are rebuilt against the new config too — not # just the in-memory settings dict. await ctx.reload_settings() diff --git a/backend/src/module/conf/log.py b/backend/src/module/conf/log.py index 1d7179a5..3df327a6 100644 --- a/backend/src/module/conf/log.py +++ b/backend/src/module/conf/log.py @@ -88,5 +88,6 @@ def setup_logger(level: int = logging.INFO, reset: bool = False): handlers=[queue_handler], ) - # Suppress verbose HTTP request logs from httpx - logging.getLogger("httpx").setLevel(logging.WARNING) + # 压低 HTTP 请求日志:httpx2 是本项目的客户端;google-genai 与 mcp 仍依赖旧 httpx + for name in ("httpx2", "httpx"): + logging.getLogger(name).setLevel(logging.WARNING) diff --git a/backend/src/module/downloader/client/aria2_downloader.py b/backend/src/module/downloader/client/aria2_downloader.py index 52bd9327..ac882ea3 100644 --- a/backend/src/module/downloader/client/aria2_downloader.py +++ b/backend/src/module/downloader/client/aria2_downloader.py @@ -5,7 +5,7 @@ import os import shutil from typing import ClassVar -import httpx +import httpx2 from module.database import Database from module.database.aria2 import Aria2RenameIntent @@ -77,7 +77,7 @@ class Aria2Downloader: # gid 只在 aria2 实例内唯一,本地映射按实例 id 区分 self.instance_id = instance_id self.secret = password - self._client: httpx.AsyncClient | None = None + self._client: httpx2.AsyncClient | None = None self._authed = False self._rpc_url = f"{host}/jsonrpc" self._id = 0 @@ -99,9 +99,9 @@ class Aria2Downloader: } try: resp = await self._client.post(self._rpc_url, json=payload, timeout=timeout) - except httpx.TimeoutException as e: + except httpx2.TimeoutException as e: raise Aria2ConnectionError(f"aria2 RPC '{method}' timed out: {e}") from e - except httpx.RequestError as e: + except httpx2.RequestError as e: raise Aria2ConnectionError( f"aria2 RPC '{method}' request failed: {e}" ) from e @@ -120,8 +120,8 @@ class Aria2Downloader: if self._client is not None and self._authed: return True if self._client is None: - self._client = httpx.AsyncClient( - timeout=httpx.Timeout(connect=3.1, read=10.0, write=10.0, pool=10.0) + self._client = httpx2.AsyncClient( + timeout=httpx2.Timeout(connect=3.1, read=10.0, write=10.0, pool=10.0) ) times = 0 while times < retry: diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index 027554fd..72890139 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -5,7 +5,7 @@ import logging import re from typing import ClassVar -import httpx +import httpx2 from module.ab_decorator import qb_connect_failed_wait from module.downloader.base import ( @@ -78,7 +78,7 @@ class QbDownloader: self.username = username self.password = password self.ssl = ssl - self._client: httpx.AsyncClient | None = None + self._client: httpx2.AsyncClient | None = None self._authed = False # 最近一次 auth 失败的原因(unreachable | credentials | banned), # 供上层(checker/startup 等待循环)区分故障类型;成功后清空。 @@ -97,21 +97,21 @@ class QbDownloader: def _url(self, endpoint: str) -> str: return f"{self.host}/api/v2/{endpoint}" - def _new_client(self) -> httpx.AsyncClient: - timeout = httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0) + def _new_client(self) -> httpx2.AsyncClient: + timeout = httpx2.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0) # Keepalive_expiry keeps idle TCP sockets short-lived so they can't # outlive a proxy / NAS idle-reap, which would otherwise surface as # "Server disconnected without sending a response" when the next # renamer cycle reuses the pool (#984). max_connections caps parallel # load on the downloader and anything fronting it. - limits = httpx.Limits( + limits = httpx2.Limits( max_keepalive_connections=5, max_connections=10, keepalive_expiry=30.0, ) # Never verify certificates - self-signed certs are the norm for # home-server / NAS / Docker qBittorrent setups. - return httpx.AsyncClient(timeout=timeout, limits=limits, verify=False) + return httpx2.AsyncClient(timeout=timeout, limits=limits, verify=False) async def auth(self, retry=3): # Session reuse: a live, already-authenticated client short-circuits so @@ -191,7 +191,7 @@ class QbDownloader: ) await asyncio.sleep(5) times += 1 - except httpx.ConnectError as e: + except httpx2.ConnectError as e: self.last_auth_error = "unreachable" if use_https: logger.error( @@ -216,7 +216,7 @@ class QbDownloader: break return False - async def _request(self, method: str, endpoint: str, **kwargs) -> httpx.Response: + async def _request(self, method: str, endpoint: str, **kwargs) -> httpx2.Response: """Issue a request, re-authenticating once if the session expired. qBittorrent answers 403 when the session cookie is no longer valid @@ -244,10 +244,10 @@ class QbDownloader: resp = await self._client.request(method, self._url(endpoint), **kwargs) return resp - async def _get(self, endpoint: str, **kwargs) -> httpx.Response: + async def _get(self, endpoint: str, **kwargs) -> httpx2.Response: return await self._request("GET", endpoint, **kwargs) - async def _post(self, endpoint: str, **kwargs) -> httpx.Response: + async def _post(self, endpoint: str, **kwargs) -> httpx2.Response: return await self._request("POST", endpoint, **kwargs) async def logout(self): @@ -255,9 +255,9 @@ class QbDownloader: try: await self._client.post(self._url("auth/logout")) except ( - httpx.ConnectError, - httpx.RequestError, - httpx.TimeoutException, + httpx2.ConnectError, + httpx2.RequestError, + httpx2.TimeoutException, ) as e: logger.debug("Logout request failed (non-critical): %s", e) await self._client.aclose() @@ -306,7 +306,7 @@ class QbDownloader: ) return None payload = resp.json() - except (ConnectionError, OSError, ValueError, httpx.RequestError) as e: + except (ConnectionError, OSError, ValueError, httpx2.RequestError) as e: logger.warning("Could not verify qBittorrent task %s: %s", torrent_hash, e) return None return isinstance(payload, list) and any( @@ -374,11 +374,11 @@ class QbDownloader: resp = await self._get("torrents/info", params={"hashes": "|".join(hashes)}) found = {t.get("hash", "").lower() for t in resp.json()} return set(hashes) <= found - except (httpx.RequestError, ValueError): + except (httpx2.RequestError, ValueError): return False @staticmethod - def _parse_add_response_json(resp: httpx.Response) -> dict | None: + def _parse_add_response_json(resp: httpx2.Response) -> dict | None: """qB >= 5.2 的 torrents/add 回 JSON 计数;旧版本回 "Ok."/"Fails."。 不是该结构(如代理返回的 HTML 错误页)时返回 None,走通用失败路径。 """ @@ -483,7 +483,7 @@ class QbDownloader: "qBittorrent rejected torrent add: " f"HTTP {resp.status_code} {resp.text!r}" ) - except (httpx.ReadError, httpx.ConnectError, httpx.RequestError) as e: + except (httpx2.ReadError, httpx2.ConnectError, httpx2.RequestError) as e: if attempt < max_retries - 1: logger.warning( f"Network error adding torrent (attempt {attempt + 1}/{max_retries}): {e}" @@ -600,7 +600,7 @@ class QbDownloader: RenameOutcome.RETRYABLE_FAILURE, detail="qBittorrent did not expose the target path after rename", ) - except (httpx.ConnectError, httpx.RequestError, httpx.TimeoutException) as e: + except (httpx2.ConnectError, httpx2.RequestError, httpx2.TimeoutException) as e: logger.warning(f"Failed to rename file {old_path}: {e}") return RenameResult(RenameOutcome.RETRYABLE_FAILURE, detail=str(e)) diff --git a/backend/src/module/network/request_url.py b/backend/src/module/network/request_url.py index 47f31140..cdc80f3f 100644 --- a/backend/src/module/network/request_url.py +++ b/backend/src/module/network/request_url.py @@ -4,8 +4,7 @@ import math from collections.abc import Mapping from typing import Any -import httpx -from httpx_socks import AsyncProxyTransport +import httpx2 from ab_sdk import points from ab_sdk.ingest import HttpRequest @@ -15,13 +14,13 @@ from module.plugin import host as plugin_host logger = logging.getLogger(__name__) # Module-level shared client for connection reuse -_shared_client: httpx.AsyncClient | None = None +_shared_client: httpx2.AsyncClient | None = None _shared_client_proxy_key: str | None = None # RSS 循环间隔 900s, 远超服务端 keep-alive 超时(60-120s) # keepalive_expiry=60 让空闲连接在过期前主动丢弃,避免复用过期连接 # max_connections=20 足够覆盖典型订阅数量 -_CONNECTION_LIMITS = httpx.Limits( +_CONNECTION_LIMITS = httpx2.Limits( max_keepalive_connections=5, max_connections=20, keepalive_expiry=60.0, @@ -33,7 +32,7 @@ HTTP_429_FALLBACK_DELAY = 10.0 HTTP_429_MAX_RETRY_AFTER = 60.0 -def _retry_after_delay(response: httpx.Response) -> float: +def _retry_after_delay(response: httpx2.Response) -> float: """从 429 响应解析等待秒数;仅接受数字形式的 Retry-After,日期形式走默认值。""" retry_after = response.headers.get("Retry-After") if retry_after is not None: @@ -54,14 +53,32 @@ def _proxy_config_key() -> str: return "" -async def get_shared_client() -> httpx.AsyncClient: +def proxy_url() -> str | None: + """settings.proxy 对应的代理 URL;未启用或类型未知时为 None。 + + socks5 由 httpx2 原生支持,目标主机名交给代理解析(等同旧的 rdns=True)。 + """ + proxy = settings.proxy + if not proxy.enable: + return None + if "http" in proxy.type: + scheme = "http" + elif proxy.type == "socks5": + scheme = "socks5" + else: + return None + auth = f"{proxy.username}:{proxy.password}@" if proxy.username else "" + return f"{scheme}://{auth}{proxy.host}:{proxy.port}" + + +async def get_shared_client() -> httpx2.AsyncClient: global _shared_client, _shared_client_proxy_key current_key = _proxy_config_key() if _shared_client is not None and _shared_client_proxy_key == current_key: return _shared_client if _shared_client is not None: await _shared_client.aclose() - timeout = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0) + timeout = httpx2.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0) # follow_redirects=True: Mikan mirrors and some CDNs respond with 302 to the # canonical host; without this, raise_for_status treats the redirect as an # error and the RSS pull fails (#983). @@ -70,34 +87,18 @@ async def get_shared_client() -> httpx.AsyncClient: "limits": _CONNECTION_LIMITS, "follow_redirects": True, } - if settings.proxy.enable: - if "http" in settings.proxy.type: - if settings.proxy.username: - proxy_url = f"http://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}" - else: - proxy_url = f"http://{settings.proxy.host}:{settings.proxy.port}" - _shared_client = httpx.AsyncClient(proxy=proxy_url, **common_kwargs) - elif settings.proxy.type == "socks5": - if settings.proxy.username: - socks_url = f"socks5://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}" - else: - socks_url = f"socks5://{settings.proxy.host}:{settings.proxy.port}" - transport = AsyncProxyTransport.from_url(socks_url, rdns=True) - _shared_client = httpx.AsyncClient(transport=transport, **common_kwargs) - else: - _shared_client = httpx.AsyncClient(**common_kwargs) - else: - _shared_client = httpx.AsyncClient(**common_kwargs) + _shared_client = httpx2.AsyncClient(proxy=proxy_url(), **common_kwargs) _shared_client_proxy_key = current_key return _shared_client -async def _replace_stale_client(stale: httpx.AsyncClient) -> httpx.AsyncClient: - """#1104: httpcore 在 HTTP 代理 CONNECT 成功、TLS 握手失败时泄漏隧道连接, +async def _replace_stale_client(stale: httpx2.AsyncClient) -> httpx2.AsyncClient: + """#1104: httpcore2 在 HTTP 代理 CONNECT 成功、TLS 握手失败时泄漏隧道连接, 连接池被占满后永久 PoolTimeout。换一个新 client 给后续请求;旧的不 aclose (其他协程可能仍在使用,见 dc786a29),交给 GC 回收。""" global _shared_client - # ponytail: 旧连接要等 GC 才释放;httpcore 修复 encode/httpcore#1087 发布后升级依赖并删除此函数 + # ponytail: 旧连接要等 GC 才释放;httpcore2 2.15.1 的 start_tls 失败路径仍未关闭隧道 + # (同 encode/httpcore#1087),修复发布后升级依赖并删除此函数 if _shared_client is stale: _shared_client = None return await get_shared_client() @@ -138,7 +139,7 @@ class RequestURL: def __init__(self): self.header = {"User-Agent": self.DEFAULT_UA, "Accept": "application/xml"} - self._client: httpx.AsyncClient | None = None + self._client: httpx2.AsyncClient | None = None def _get_headers(self, url: str) -> dict: """Get appropriate headers based on URL type.""" @@ -173,7 +174,7 @@ class RequestURL: ) req.raise_for_status() return req - except httpx.HTTPStatusError as e: + except httpx2.HTTPStatusError as e: logger.warning(f"HTTP {e.response.status_code} from {url}") if e.response.status_code != 429: break @@ -187,11 +188,11 @@ class RequestURL: f"({try_time}/{retry})" ) await asyncio.sleep(delay) - except httpx.RequestError as e: + except httpx2.RequestError as e: logger.warning( f"Request error for {url}: {type(e).__name__}. Retry {try_time + 1}/{retry}" ) - if isinstance(e, httpx.PoolTimeout): + if isinstance(e, httpx2.PoolTimeout): self._client = await _replace_stale_client(self._client) try_time += 1 if try_time >= retry: @@ -218,7 +219,7 @@ class RequestURL: req = await self._client.post(url=url, headers=self.header, data=data) req.raise_for_status() return req - except httpx.HTTPStatusError as e: + except httpx2.HTTPStatusError as e: # 服务端拒绝不是连接失败,重试无意义;把状态码和响应体 # (如 Telegram 的 JSON description)记下来便于排查(#1094)。 logger.warning( @@ -228,9 +229,9 @@ class RequestURL: e.response.text[:200], ) break - except httpx.RequestError as e: + except httpx2.RequestError as e: logger.warning(f"Cannot connect to {url}. Wait for 5 seconds.") - if isinstance(e, httpx.PoolTimeout): + if isinstance(e, httpx2.PoolTimeout): self._client = await _replace_stale_client(self._client) try_time += 1 if try_time >= retry: @@ -254,7 +255,7 @@ class RequestURL: ) req.raise_for_status() return req - except httpx.HTTPStatusError as e: + except httpx2.HTTPStatusError as e: # 别把服务端拒绝(4xx/5xx)误报成连接失败:响应体里通常带有明确的 # 拒绝原因(如 Telegram 的 JSON description),排查 #1094 全靠它。 logger.warning( @@ -264,12 +265,12 @@ class RequestURL: e.response.text[:200], ) return None - except httpx.RequestError: + except httpx2.RequestError: logger.warning(f"Cannot connect to {url}.") return None async def post_json(self, url: str, json_data: dict, headers: dict | None = None): - """POST a JSON body (httpx ``json=``, not form-encoded ``data=``). + """POST a JSON body (httpx2 ``json=``, not form-encoded ``data=``). Used by JSON-body notification providers (Discord, Webhook, Gotify, Bark) that reject form-encoded requests. @@ -283,7 +284,7 @@ class RequestURL: ) req.raise_for_status() return req - except (httpx.RequestError, httpx.HTTPStatusError): + except (httpx2.RequestError, httpx2.HTTPStatusError): logger.warning(f"Cannot connect to {url}.") return None diff --git a/backend/src/module/notification/base.py b/backend/src/module/notification/base.py index 97cd1d4d..82078423 100644 --- a/backend/src/module/notification/base.py +++ b/backend/src/module/notification/base.py @@ -151,13 +151,13 @@ class NotificationProvider(ABC): return await self._http.post_files(url, data, files) async def _post_json(self, url: str, json_data: dict): - """POST a JSON payload via ``RequestURL.post_json`` (httpx ``json=``). + """POST a JSON payload via ``RequestURL.post_json`` (httpx2 ``json=``). Args: url: The target URL. json_data: The JSON-serializable payload. Returns: - The httpx response. + The httpx2 response. """ return await self._http.post_json(url, json_data) diff --git a/backend/src/module/parser/analyser/llm.py b/backend/src/module/parser/analyser/llm.py index 26e498b4..1c806e3d 100644 --- a/backend/src/module/parser/analyser/llm.py +++ b/backend/src/module/parser/analyser/llm.py @@ -3,8 +3,8 @@ 具体提供商逻辑在 ``providers/`` 包中(内置 openai/anthropic/gemini 适配器 + 注册表);本模块保留三样东西以维持既有 patch/import 路径: -- ``_build_http_client``:代理感知的 httpx 客户端工厂(测试 patch - ``module.parser.analyser.llm.httpx`` / ``AsyncProxyTransport``); +- ``_build_http_client``:代理感知的 httpx2 客户端工厂(测试 patch + ``module.parser.analyser.llm.httpx2``); - ``Episode``/``EPISODE_JSON_SCHEMA``/``DEFAULT_PROMPT`` 等共享契约的 重导出(物理定义在 providers/schema.py); - ``LLMParser``:构造签名不变的薄门面,将 ``_openai_client`` 等内部 @@ -14,10 +14,9 @@ import logging from typing import Any -import httpx -from httpx_socks import AsyncProxyTransport +import httpx2 -from module.conf import settings +from module.network.request_url import proxy_url from .providers.base import AdapterContext from .providers.credentials import CredentialStore @@ -107,34 +106,13 @@ class LLMParser: return result -def _build_http_client(timeout: float) -> httpx.AsyncClient: - http_timeout = httpx.Timeout( +def _build_http_client(timeout: float) -> httpx2.AsyncClient: + http_timeout = httpx2.Timeout( connect=min(timeout, 10.0), read=timeout, write=min(timeout, 10.0), pool=min(timeout, 10.0), ) - kwargs: dict[str, Any] = {"timeout": http_timeout, "follow_redirects": True} - if not settings.proxy.enable: - return httpx.AsyncClient(**kwargs) - if "http" in settings.proxy.type: - if settings.proxy.username: - proxy_url = ( - f"http://{settings.proxy.username}:{settings.proxy.password}" - f"@{settings.proxy.host}:{settings.proxy.port}" - ) - else: - proxy_url = f"http://{settings.proxy.host}:{settings.proxy.port}" - return httpx.AsyncClient(proxy=proxy_url, **kwargs) - if settings.proxy.type == "socks5": - if settings.proxy.username: - proxy_url = ( - f"socks5://{settings.proxy.username}:{settings.proxy.password}" - f"@{settings.proxy.host}:{settings.proxy.port}" - ) - else: - proxy_url = f"socks5://{settings.proxy.host}:{settings.proxy.port}" - return httpx.AsyncClient( - transport=AsyncProxyTransport.from_url(proxy_url, rdns=True), **kwargs - ) - return httpx.AsyncClient(**kwargs) + return httpx2.AsyncClient( + proxy=proxy_url(), timeout=http_timeout, follow_redirects=True + ) diff --git a/backend/src/module/parser/analyser/providers/builtin.py b/backend/src/module/parser/analyser/providers/builtin.py index 9c75d633..c077c689 100644 --- a/backend/src/module/parser/analyser/providers/builtin.py +++ b/backend/src/module/parser/analyser/providers/builtin.py @@ -52,7 +52,7 @@ class OpenAIAdapter(LLMProviderAdapter): import openai as openai_sdk try: - resp = await self._openai_client.beta.chat.completions.parse( + resp = await self._openai_client.chat.completions.parse( model=self.model, messages=[ {"role": "system", "content": DEFAULT_PROMPT}, diff --git a/backend/src/module/plugin/installer.py b/backend/src/module/plugin/installer.py index a634a610..7a061c08 100644 --- a/backend/src/module/plugin/installer.py +++ b/backend/src/module/plugin/installer.py @@ -25,7 +25,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, ClassVar, Optional -import httpx +import httpx2 import semver from ab_sdk.manifest import ID_RE, MANIFEST_NAME, RESERVED_IDS, check @@ -89,7 +89,7 @@ class SignedCatalogInstaller: *, root: Optional[Path] = None, pubkey_path: Optional[Path] = None, - client: Optional[httpx.AsyncClient] = None, + client: Optional[httpx2.AsyncClient] = None, app_version: str = "0.0.0", ) -> None: self.root = root if root is not None else DEFAULT_PLUGINS_ROOT @@ -123,7 +123,7 @@ class SignedCatalogInstaller: # ------------------------------------------------------------ 下载 - async def _get_client(self) -> httpx.AsyncClient: + async def _get_client(self) -> httpx2.AsyncClient: if self._client is not None: return self._client from module.network.request_url import get_shared_client diff --git a/backend/src/module/plugins/builtin/media-server-refresh/media_server_refresh.py b/backend/src/module/plugins/builtin/media-server-refresh/media_server_refresh.py index fd8d0f2a..7e0894fa 100644 --- a/backend/src/module/plugins/builtin/media-server-refresh/media_server_refresh.py +++ b/backend/src/module/plugins/builtin/media-server-refresh/media_server_refresh.py @@ -12,7 +12,7 @@ import asyncio from collections.abc import Callable from typing import Literal -import httpx +import httpx2 from pydantic import BaseModel, Field from ab_sdk import Plugin, PluginContext, secret_field, subscribe @@ -65,8 +65,8 @@ class MediaServerRefresh(Plugin[Options]): # 刷新请求发出后又有新事件:这次刷新之后再排一次 self._again = False # 测试可替换为带 MockTransport 的客户端 - self.client_factory: Callable[[], httpx.AsyncClient] = lambda: ( - httpx.AsyncClient(timeout=10) + self.client_factory: Callable[[], httpx2.AsyncClient] = lambda: ( + httpx2.AsyncClient(timeout=10) ) @property diff --git a/backend/src/module/update/updater.py b/backend/src/module/update/updater.py index 489abb54..a71d9edd 100644 --- a/backend/src/module/update/updater.py +++ b/backend/src/module/update/updater.py @@ -29,7 +29,7 @@ from dataclasses import asdict, dataclass from pathlib import Path from typing import Callable, Optional -import httpx +import httpx2 import semver from pydantic import BaseModel @@ -174,7 +174,7 @@ class Updater: data_db: Optional[Path] = None, app_root: Optional[Path] = None, dependency_syncer: Optional[Callable[[Path], None]] = None, - client: Optional[httpx.AsyncClient] = None, + client: Optional[httpx2.AsyncClient] = None, owner: str = GITHUB_OWNER, repo: str = GITHUB_REPO, pubkey_path: Optional[Path] = None, @@ -210,7 +210,7 @@ class Updater: # -------------------------------------------------------------- HTTP - async def _get_client(self) -> httpx.AsyncClient: + async def _get_client(self) -> httpx2.AsyncClient: if self._client is not None: return self._client return await get_shared_client() diff --git a/backend/src/module/utils/json_config.py b/backend/src/module/utils/json_config.py index 4bacf55b..555a60aa 100644 --- a/backend/src/module/utils/json_config.py +++ b/backend/src/module/utils/json_config.py @@ -1,6 +1,6 @@ import json -import httpx +import httpx2 def load(filename): @@ -14,6 +14,6 @@ def save(filename, obj): async def get(url): - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: req = await client.get(url) return req.json() diff --git a/backend/src/test/e2e/downloader/conftest.py b/backend/src/test/e2e/downloader/conftest.py index 8832392d..654a7f02 100644 --- a/backend/src/test/e2e/downloader/conftest.py +++ b/backend/src/test/e2e/downloader/conftest.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -import httpx +import httpx2 import pytest from test.e2e.support import REPO_ROOT, wait_until @@ -107,7 +107,7 @@ class DownloaderStack: def image_version(self, container_id: str) -> str: return self.run("docker", "exec", container_id, "cat", "/app/IMAGE_VERSION") - def wait_for_health(self, client: httpx.Client) -> dict[str, Any]: + def wait_for_health(self, client: httpx2.Client) -> dict[str, Any]: def probe() -> dict[str, Any]: response = client.get("/health") return { diff --git a/backend/src/test/e2e/downloader/test_qb_image_contract.py b/backend/src/test/e2e/downloader/test_qb_image_contract.py index 1dbaa840..c2d341c6 100644 --- a/backend/src/test/e2e/downloader/test_qb_image_contract.py +++ b/backend/src/test/e2e/downloader/test_qb_image_contract.py @@ -4,7 +4,7 @@ from __future__ import annotations import re -import httpx +import httpx2 import pytest pytestmark = pytest.mark.e2e @@ -26,7 +26,7 @@ def test_exact_digest_qb_image_uses_only_local_webseed_capabilities( assert compose["services"]["qbittorrent"]["image"] == QB_IMAGE downloader_stack.image_identity("qbittorrent", QB_IMAGE) - with httpx.Client( + with httpx2.Client( base_url=downloader_stack.qb_url, timeout=10, trust_env=False, diff --git a/backend/src/test/e2e/downloader/test_release_image_smoke.py b/backend/src/test/e2e/downloader/test_release_image_smoke.py index 71f3b11d..af34dff1 100644 --- a/backend/src/test/e2e/downloader/test_release_image_smoke.py +++ b/backend/src/test/e2e/downloader/test_release_image_smoke.py @@ -5,7 +5,7 @@ from __future__ import annotations import os import re -import httpx +import httpx2 import pytest pytestmark = pytest.mark.e2e @@ -13,7 +13,7 @@ pytestmark = pytest.mark.e2e _SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$") -def _ensure_setup(client: httpx.Client, downloader_stack) -> None: +def _ensure_setup(client: httpx2.Client, downloader_stack) -> None: status = client.get("/api/v1/setup/status") assert status.status_code == 200 if not status.json()["need_setup"]: @@ -46,7 +46,7 @@ def test_native_image_version_and_config_survive_restart(downloader_stack): assert app_service["pull_policy"] == "never" assert not app_image.endswith(":latest") - with httpx.Client( + with httpx2.Client( base_url=downloader_stack.base_url, timeout=10, trust_env=False, diff --git a/backend/src/test/e2e/runtime/test_fake_qb_contract.py b/backend/src/test/e2e/runtime/test_fake_qb_contract.py index e50e659c..e50c374d 100644 --- a/backend/src/test/e2e/runtime/test_fake_qb_contract.py +++ b/backend/src/test/e2e/runtime/test_fake_qb_contract.py @@ -4,7 +4,7 @@ import runpy import threading from pathlib import Path -import httpx +import httpx2 import pytest pytestmark = pytest.mark.e2e @@ -32,7 +32,7 @@ def fake_qb_url(): thread.join(timeout=5) -def login(client: httpx.Client) -> None: +def login(client: httpx2.Client) -> None: response = client.post( "/api/v2/auth/login", data={"username": "admin", "password": "adminadmin"}, @@ -42,7 +42,7 @@ def login(client: httpx.Client) -> None: def test_qb_endpoints_and_successful_delete_update_state(fake_qb_url): - with httpx.Client(base_url=fake_qb_url) as client: + with httpx2.Client(base_url=fake_qb_url) as client: login(client) assert client.get("/api/v2/app/version").text == "v5.2.3" @@ -76,7 +76,7 @@ def test_qb_endpoints_and_successful_delete_update_state(fake_qb_url): def test_forced_delete_failure_is_atomic(fake_qb_url): - with httpx.Client(base_url=fake_qb_url) as client: + with httpx2.Client(base_url=fake_qb_url) as client: login(client) response = client.post( @@ -95,7 +95,7 @@ def test_forced_delete_failure_is_atomic(fake_qb_url): def test_request_journal_preserves_form_shape_and_redacts_secrets(fake_qb_url): - with httpx.Client(base_url=fake_qb_url) as client: + with httpx2.Client(base_url=fake_qb_url) as client: login(client) response = client.post( @@ -137,7 +137,7 @@ def test_request_journal_preserves_form_shape_and_redacts_secrets(fake_qb_url): def test_admin_reset_restores_presets_and_clears_journal(fake_qb_url): - with httpx.Client(base_url=fake_qb_url) as client: + with httpx2.Client(base_url=fake_qb_url) as client: login(client) client.post( "/api/v2/torrents/delete", diff --git a/backend/src/test/e2e/runtime/test_mock_upstream_contract.py b/backend/src/test/e2e/runtime/test_mock_upstream_contract.py index 73bb7c99..62b4630f 100644 --- a/backend/src/test/e2e/runtime/test_mock_upstream_contract.py +++ b/backend/src/test/e2e/runtime/test_mock_upstream_contract.py @@ -6,7 +6,7 @@ import shutil import threading from pathlib import Path -import httpx +import httpx2 import pytest pytestmark = pytest.mark.e2e @@ -40,7 +40,7 @@ def upstream(fixture_root: Path): thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() host, port = server.server_address[:2] - with httpx.Client(base_url=f"http://{host}:{port}", timeout=5.0) as client: + with httpx2.Client(base_url=f"http://{host}:{port}", timeout=5.0) as client: yield client, fixture_root server.shutdown() server.server_close() @@ -48,7 +48,7 @@ def upstream(fixture_root: Path): assert not thread.is_alive() -def _activate(client: httpx.Client, name: str) -> None: +def _activate(client: httpx2.Client, name: str) -> None: response = client.put(f"/__admin/scenario/{name}") assert response.status_code == 200 assert response.json()["scenario"] == name diff --git a/backend/src/test/e2e/runtime/test_packaged_image_persistence.py b/backend/src/test/e2e/runtime/test_packaged_image_persistence.py index 52bc9176..d046c7aa 100644 --- a/backend/src/test/e2e/runtime/test_packaged_image_persistence.py +++ b/backend/src/test/e2e/runtime/test_packaged_image_persistence.py @@ -10,7 +10,7 @@ import subprocess from pathlib import Path from typing import Any -import httpx +import httpx2 import pytest from test.e2e.support import REPO_ROOT, wait_until @@ -96,7 +96,7 @@ def _image_version(container_id: str) -> str: return _run("docker", "exec", container_id, "cat", "/app/IMAGE_VERSION") -def _wait_for_health(client: httpx.Client) -> dict[str, Any]: +def _wait_for_health(client: httpx2.Client) -> dict[str, Any]: def probe() -> dict[str, Any]: response = client.get("/health") return { @@ -119,7 +119,7 @@ def _wait_for_health(client: httpx.Client) -> dict[str, Any]: def test_packaged_image_version_and_config_survive_container_restart(): environment = _stack_environment() - with httpx.Client( + with httpx2.Client( base_url=environment["AB_E2E_BASE_URL"], timeout=10, trust_env=False, diff --git a/backend/src/test/e2e/runtime/test_port_proxy_contract.py b/backend/src/test/e2e/runtime/test_port_proxy_contract.py index 144fb8ce..9fd0c921 100644 --- a/backend/src/test/e2e/runtime/test_port_proxy_contract.py +++ b/backend/src/test/e2e/runtime/test_port_proxy_contract.py @@ -6,7 +6,7 @@ import threading from http.server import BaseHTTPRequestHandler from pathlib import Path -import httpx +import httpx2 import pytest pytestmark = pytest.mark.e2e @@ -57,7 +57,7 @@ def test_tcp_proxy_forwards_http_without_interpreting_the_protocol(): proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True) proxy_thread.start() try: - response = httpx.get( + response = httpx2.get( f"http://127.0.0.1:{proxy.server_address[1]}/health?through=proxy", timeout=5, trust_env=False, diff --git a/backend/src/test/e2e/support/runtime.py b/backend/src/test/e2e/support/runtime.py index e42ebe6f..a1a0eb45 100644 --- a/backend/src/test/e2e/support/runtime.py +++ b/backend/src/test/e2e/support/runtime.py @@ -21,7 +21,7 @@ from pathlib import Path from time import monotonic from typing import Any, TypeVar -import httpx +import httpx2 REPO_ROOT = Path(__file__).resolve().parents[5] BACKEND_SRC = REPO_ROOT / "backend" / "src" @@ -102,7 +102,7 @@ class HttpService: host, port = server.server_address[:2] connect_host = "127.0.0.1" if host in {"", "0.0.0.0", "::"} else host self.base_url = f"http://{connect_host}:{port}" - self.client = httpx.Client( + self.client = httpx2.Client( base_url=self.base_url, timeout=5.0, trust_env=False, @@ -167,7 +167,7 @@ class BackendSandbox: self._log_handle: Any = None self.port = 0 self.base_url = "" - self.client: httpx.Client + self.client: httpx2.Client self._assign_unused_port() self.env = self._build_env() @@ -225,8 +225,8 @@ class BackendSandbox: self, *, headers: Mapping[str, str] | None = None, - ) -> httpx.Client: - return httpx.Client( + ) -> httpx2.Client: + return httpx2.Client( base_url=self.base_url, headers=headers, timeout=10.0, @@ -363,7 +363,7 @@ class BackendSandbox: downloader_username: str = "", downloader_password: str = "", downloader_path: str = "/downloads/Bangumi", - ) -> httpx.Response: + ) -> httpx2.Response: return self.client.post( "/api/v1/setup/complete", json={ @@ -386,8 +386,8 @@ class BackendSandbox: *, username: str = E2E_USERNAME, password: str = E2E_PASSWORD, - client: httpx.Client | None = None, - ) -> httpx.Response: + client: httpx2.Client | None = None, + ) -> httpx2.Response: target = client or self.client return target.post( "/api/v1/auth/login", diff --git a/backend/src/test/test_api_users.py b/backend/src/test/test_api_users.py index 47fa3f6b..72674f15 100644 --- a/backend/src/test/test_api_users.py +++ b/backend/src/test/test_api_users.py @@ -2,7 +2,7 @@ from typing import Any -import httpx +import httpx2 import pytest from module.database import Database @@ -13,8 +13,8 @@ async def test_multiple_users_and_sessions_survive_client_recreation(app): async with Database() as db: await db.user.create_user("first_user", "first-password") - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + transport = httpx2.ASGITransport(app=app) + async with httpx2.AsyncClient(transport=transport, base_url="http://test") as client: login = await client.post( "/api/v1/auth/login", data={"username": "first_user", "password": "first-password"}, @@ -34,7 +34,7 @@ async def test_multiple_users_and_sessions_survive_client_recreation(app): "second_user", } - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx2.AsyncClient(transport=transport, base_url="http://test") as client: client.cookies.set("token", persisted_cookie) me = await client.get("/api/v1/auth/me") assert me.status_code == 200 @@ -48,8 +48,8 @@ async def test_last_enabled_user_conflict_and_session_only_route_matrix(app): async with Database() as db: only = await db.user.create_user("only_user", "only-password") - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + transport = httpx2.ASGITransport(app=app) + async with httpx2.AsyncClient(transport=transport, base_url="http://test") as client: assert ( await client.post( "/api/v1/auth/login", @@ -72,7 +72,7 @@ async def test_last_enabled_user_conflict_and_session_only_route_matrix(app): token_id = listed.json()[0]["id"] headers = {"Authorization": f"Bearer {raw_token}"} - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx2.AsyncClient(transport=transport, base_url="http://test") as client: assert (await client.get("/api/v1/auth/me", headers=headers)).status_code == 200 session_only_requests: list[tuple[str, str, dict[str, Any]]] = [ ("GET", "/api/v1/users", {}), diff --git a/backend/src/test/test_aria2_downloader.py b/backend/src/test/test_aria2_downloader.py index a8d5477f..86d81b90 100644 --- a/backend/src/test/test_aria2_downloader.py +++ b/backend/src/test/test_aria2_downloader.py @@ -11,7 +11,7 @@ verified by patching ``Aria2Downloader._call`` and letting the real logic import os from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from module.database import Database @@ -92,7 +92,7 @@ class TestCallRpcLayer: async def test_call_timeout_raises_connection_error(self): aria2 = _aria2() aria2._client = AsyncMock() - aria2._client.post = AsyncMock(side_effect=httpx.TimeoutException("timed out")) + aria2._client.post = AsyncMock(side_effect=httpx2.TimeoutException("timed out")) with pytest.raises(Aria2ConnectionError): await aria2._call("getVersion") @@ -100,7 +100,7 @@ class TestCallRpcLayer: async def test_call_request_error_raises_connection_error(self): aria2 = _aria2() aria2._client = AsyncMock() - aria2._client.post = AsyncMock(side_effect=httpx.ConnectError("refused")) + aria2._client.post = AsyncMock(side_effect=httpx2.ConnectError("refused")) with pytest.raises(Aria2ConnectionError): await aria2._call("getVersion") diff --git a/backend/src/test/test_download_session_reuse.py b/backend/src/test/test_download_session_reuse.py index e907cdd0..12624974 100644 --- a/backend/src/test/test_download_session_reuse.py +++ b/backend/src/test/test_download_session_reuse.py @@ -23,7 +23,7 @@ def _patch_qb_settings(mock_settings, host="localhost:8080"): class _FakeHTTP: - """Minimal httpx.AsyncClient stand-in counting logins and data requests.""" + """Minimal httpx2.AsyncClient stand-in counting logins and data requests.""" def __init__(self, login_calls: list, request_calls: list): self._login_calls = login_calls @@ -62,7 +62,7 @@ class TestSessionReuse: with patch("module.downloader.download_client.settings") as mock_settings: _patch_qb_settings(mock_settings) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", side_effect=make_http, ): async with DownloadClient(): @@ -98,7 +98,7 @@ class TestSessionReuse: pass with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=_Fake403Once(), ): await qb.auth() @@ -140,7 +140,7 @@ class TestSessionReuse: return client with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", side_effect=make_http, ): assert await qb.auth() is True @@ -168,7 +168,7 @@ class TestSessionReuse: pass with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=_SlowLogin(), ): r1, r2 = await asyncio.gather(qb.auth(), qb.auth()) @@ -178,7 +178,7 @@ class TestSessionReuse: class _FakeRejectHTTP: - """登录永远被拒(错误密码)的 httpx 替身。""" + """登录永远被拒(错误密码)的 httpx2 替身。""" def __init__(self, login_calls: list): self._login_calls = login_calls @@ -204,7 +204,7 @@ class TestCredentialFailureLatch: with patch("module.downloader.download_client.settings") as mock_settings: _patch_qb_settings(mock_settings) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", side_effect=lambda *a, **k: _FakeRejectHTTP(login_calls), ): for _ in range(3): # 三个先后到来的 loop tick @@ -233,7 +233,7 @@ class TestCredentialFailureLatch: qb = QbDownloader("localhost:8080", "admin", "wrong", ssl=False) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=_SlowReject(), ): r = await asyncio.gather(qb.auth(), qb.auth(), qb.auth()) @@ -250,7 +250,7 @@ class TestCredentialFailureLatch: with patch("module.downloader.download_client.settings") as mock_settings: _patch_qb_settings(mock_settings) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", side_effect=lambda *a, **k: _FakeRejectHTTP(login_calls), ): with pytest.raises(ConnectionError): @@ -286,7 +286,7 @@ class TestRetiredClientLifecycle: with patch("module.downloader.download_client.settings") as mock_settings: with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", side_effect=lambda *a, **k: _TrackedHTTP(), ): _patch_qb_settings(mock_settings, host="host-a:8080") @@ -321,7 +321,7 @@ class TestRetiredClientLifecycle: with patch("module.downloader.download_client.settings") as mock_settings: _patch_qb_settings(mock_settings) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=_HangingLogin(), ): @@ -369,7 +369,7 @@ class TestRetiredClientLifecycle: with patch("module.downloader.download_client.settings") as mock_settings: with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", side_effect=[_SlowLoginHTTP(), _FastHTTP()], ): _patch_qb_settings(mock_settings, host="host-a:8080") diff --git a/backend/src/test/test_llm_parser.py b/backend/src/test/test_llm_parser.py index b32bd20d..6aa85e1b 100644 --- a/backend/src/test/test_llm_parser.py +++ b/backend/src/test/test_llm_parser.py @@ -6,11 +6,12 @@ from typing import Any from unittest.mock import AsyncMock, patch import anthropic -import httpx +import httpx2 import openai as openai_sdk import pytest from google.genai import errors as genai_errors +from module.conf import settings from module.models.config import Proxy from module.parser.analyser import llm as llm_module from module.parser.analyser.llm import ( @@ -56,28 +57,21 @@ class TestLLMParserInit: username="user", password="pass", ) - with patch.object(llm_module.settings, "proxy", proxy): - with patch("module.parser.analyser.llm.httpx.AsyncClient") as mock_client: + with patch.object(settings, "proxy", proxy): + with patch("module.parser.analyser.llm.httpx2.AsyncClient") as mock_client: llm_module._build_http_client(5.0) assert mock_client.call_args.kwargs["proxy"] == ( "http://user:pass@proxy.local:7890" ) - def test_socks_proxy_uses_proxy_transport(self): + def test_socks_proxy_is_passed_to_injected_http_client(self): proxy = Proxy(enable=True, type="socks5", host="proxy.local", port=7891) - with patch.object(llm_module.settings, "proxy", proxy): - with ( - patch("module.parser.analyser.llm.httpx.AsyncClient") as mock_client, - patch( - "module.parser.analyser.llm.AsyncProxyTransport.from_url", - return_value="transport", - ) as mock_transport, - ): + with patch.object(settings, "proxy", proxy): + with patch("module.parser.analyser.llm.httpx2.AsyncClient") as mock_client: llm_module._build_http_client(5.0) - mock_transport.assert_called_once_with("socks5://proxy.local:7891", rdns=True) - assert mock_client.call_args.kwargs["transport"] == "transport" + assert mock_client.call_args.kwargs["proxy"] == "socks5://proxy.local:7891" class TestOpenAIProvider: @@ -102,7 +96,7 @@ class TestOpenAIProvider: ] ) mock_create = AsyncMock(return_value=stub_resp) - parser._openai_client.beta.chat.completions.parse = mock_create # type: ignore[method-assign] + parser._openai_client.chat.completions.parse = mock_create # type: ignore[method-assign] result = await parser.parse(RAW_TITLE) @@ -115,9 +109,9 @@ class TestOpenAIProvider: async def test_parse_api_error_returns_none(self): parser = self._make_parser() - parser._openai_client.beta.chat.completions.parse = AsyncMock( # type: ignore[method-assign] + parser._openai_client.chat.completions.parse = AsyncMock( # type: ignore[method-assign] side_effect=openai_sdk.APIConnectionError( - request=httpx.Request("POST", "https://api.openai.com/v1") + request=httpx2.Request("POST", "https://api.openai.com/v1") ) ) assert await parser.parse(RAW_TITLE) is None @@ -127,7 +121,7 @@ class TestOpenAIProvider: stub_resp = SimpleNamespace( choices=[SimpleNamespace(message=SimpleNamespace(parsed=None))] ) - parser._openai_client.beta.chat.completions.parse = AsyncMock( # type: ignore[method-assign] + parser._openai_client.chat.completions.parse = AsyncMock( # type: ignore[method-assign] return_value=stub_resp ) assert await parser.parse(RAW_TITLE) is None @@ -176,16 +170,16 @@ class TestAnthropicProvider: parser = self._make_parser() parser._anthropic_client.messages.create = AsyncMock( # type: ignore[method-assign] side_effect=anthropic.APIConnectionError( - request=httpx.Request("POST", "https://api.anthropic.com") + request=httpx2.Request("POST", "https://api.anthropic.com") ) ) assert await parser.parse(RAW_TITLE) is None async def test_parse_rate_limit_returns_none(self): parser = self._make_parser() - response = httpx.Response( + response = httpx2.Response( status_code=429, - request=httpx.Request("POST", "https://api.anthropic.com"), + request=httpx2.Request("POST", "https://api.anthropic.com"), ) parser._anthropic_client.messages.create = AsyncMock( # type: ignore[method-assign] side_effect=anthropic.RateLimitError( diff --git a/backend/src/test/test_llm_plugin_installer.py b/backend/src/test/test_llm_plugin_installer.py index e03a5c1f..e86ef87c 100644 --- a/backend/src/test/test_llm_plugin_installer.py +++ b/backend/src/test/test_llm_plugin_installer.py @@ -1,6 +1,6 @@ """LLM 插件安装管线:签名下载 → 校验 → 解包 → 加载 → 解析(克隆 updater 模式)。 -用测试 ed25519 密钥对 + 内存构造的假插件 zip + httpx.MockTransport + tmp_path, +用测试 ed25519 密钥对 + 内存构造的假插件 zip + httpx2.MockTransport + tmp_path, 覆盖安全路径:sha 不符、无签名、坏签名、zip-slip、版本不兼容、id 不匹配全部拒绝。 """ @@ -10,7 +10,7 @@ import io import json import zipfile -import httpx +import httpx2 import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import ( @@ -108,21 +108,21 @@ def _installer(tmp_path, keypair, zip_bytes, *, catalog=None, tamper_sig=False): if tamper_sig: sig = base64.b64encode(b"\x00" * 64).decode() - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: url = str(request.url) if url.endswith("catalog.json"): - return httpx.Response(200, content=catalog_bytes) + return httpx2.Response(200, content=catalog_bytes) if url.endswith("catalog.json.sig"): - return httpx.Response(200, text=_sign(priv, catalog_bytes)) + return httpx2.Response(200, text=_sign(priv, catalog_bytes)) if url.endswith(".zip"): - return httpx.Response(200, content=zip_bytes) + return httpx2.Response(200, content=zip_bytes) if url.endswith(".zip.sig"): - return httpx.Response(200, text=sig) + return httpx2.Response(200, text=sig) if url.endswith(".zip.sha256"): - return httpx.Response(200, text=hashlib.sha256(zip_bytes).hexdigest()) - return httpx.Response(404) + return httpx2.Response(200, text=hashlib.sha256(zip_bytes).hexdigest()) + return httpx2.Response(404) - client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) return PluginInstaller( root=tmp_path / "plugins", pubkey_path=pubkey_path, diff --git a/backend/src/test/test_llm_plugins_grey.py b/backend/src/test/test_llm_plugins_grey.py index 23a2412c..7de65271 100644 --- a/backend/src/test/test_llm_plugins_grey.py +++ b/backend/src/test/test_llm_plugins_grey.py @@ -12,7 +12,7 @@ import zipfile from pathlib import Path from unittest.mock import AsyncMock -import httpx +import httpx2 import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat @@ -66,20 +66,20 @@ async def _install(tmp_path, keypair, plugin_id): def handler(request): url = str(request.url) if url.endswith("catalog.json"): - return httpx.Response(200, content=catalog_bytes) + return httpx2.Response(200, content=catalog_bytes) if url.endswith("catalog.json.sig"): - return httpx.Response( + return httpx2.Response( 200, text=base64.b64encode(priv.sign(catalog_bytes)).decode() ) if url.endswith(".zip"): - return httpx.Response(200, content=zip_bytes) + return httpx2.Response(200, content=zip_bytes) if url.endswith(".zip.sig"): - return httpx.Response( + return httpx2.Response( 200, text=base64.b64encode(priv.sign(zip_bytes)).decode() ) - return httpx.Response(404) + return httpx2.Response(404) - client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) installer = PluginInstaller( root=tmp_path / "plugins", pubkey_path=pubkey_path, @@ -95,7 +95,7 @@ def _ctx(handler, credentials=None, model=""): """构造注入 MockTransport-http-client 的 AdapterContext。""" def build_client(_timeout): - return httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) return AdapterContext( model=model, @@ -127,7 +127,7 @@ class TestGithubCopilot: def handler(request): assert "login/device/code" in str(request.url) - return httpx.Response( + return httpx2.Response( 200, json={ "device_code": "DC-secret", @@ -155,7 +155,7 @@ class TestGithubCopilot: cls = registry.resolve("github-copilot") def handler(request): - return httpx.Response(200, json={"error": "authorization_pending"}) + return httpx2.Response(200, json={"error": "authorization_pending"}) adapter = cls(_ctx(handler)) with pytest.raises(AuthExpiredError): @@ -189,13 +189,13 @@ class TestGithubCopilot: if "copilot_internal/v2/token" in url: seen["copilot_token_fetched"] = True assert request.headers["authorization"].startswith("token ghu_") - return httpx.Response( + return httpx2.Response( 200, json={"token": "cop_tok", "expires_at": 9e18} ) if "chat/completions" in url: seen["editor_header"] = "editor-version" in request.headers assert request.headers["authorization"] == "Bearer cop_tok" - return httpx.Response( + return httpx2.Response( 200, json={ "choices": [ @@ -209,7 +209,7 @@ class TestGithubCopilot: ] }, ) - return httpx.Response(404) + return httpx2.Response(404) creds = AsyncMock() creds.load = AsyncMock(return_value=TokenSet(access_token="ghu_abc")) @@ -228,7 +228,7 @@ class TestGithubCopilot: cls = registry.resolve("github-copilot") creds = AsyncMock() creds.load = AsyncMock(return_value=None) - adapter = cls(_ctx(lambda r: httpx.Response(404), credentials=creds)) + adapter = cls(_ctx(lambda r: httpx2.Response(404), credentials=creds)) with pytest.raises(AuthExpiredError): await adapter.parse("raw") await adapter.aclose() @@ -267,7 +267,7 @@ class TestCodexChatgpt: fake_jwt = f"{header}.{claims}.sig" def handler(request): - return httpx.Response( + return httpx2.Response( 200, json={ "access_token": fake_jwt, @@ -308,7 +308,7 @@ class TestCodexChatgpt: def handler(request): seen["originator"] = request.headers.get("originator") seen["account"] = request.headers.get("chatgpt-account-id") - return httpx.Response( + return httpx2.Response( 200, json={"output": [{"content": [{"text": json.dumps(episode)}]}]}, ) @@ -338,10 +338,10 @@ class TestCodexChatgpt: def handler(request): url = str(request.url) if url.endswith("oauth/token"): - return httpx.Response( + return httpx2.Response( 200, json={"access_token": "new-at", "expires_in": 3600} ) - return httpx.Response( + return httpx2.Response( 200, json={ "output": [ diff --git a/backend/src/test/test_llm_providers_presets.py b/backend/src/test/test_llm_providers_presets.py index 3396af19..f328bb57 100644 --- a/backend/src/test/test_llm_providers_presets.py +++ b/backend/src/test/test_llm_providers_presets.py @@ -107,7 +107,7 @@ class TestJsonObjectFallback: ] ) ) - parser._openai_client.beta.chat.completions.parse = mock_parse + parser._openai_client.chat.completions.parse = mock_parse assert await parser.parse("raw") == EPISODE_DICT mock_parse.assert_awaited_once() diff --git a/backend/src/test/test_media_server_refresh.py b/backend/src/test/test_media_server_refresh.py index 8f800e8a..970cfd55 100644 --- a/backend/src/test/test_media_server_refresh.py +++ b/backend/src/test/test_media_server_refresh.py @@ -4,7 +4,7 @@ import asyncio import logging from types import SimpleNamespace -import httpx +import httpx2 import pytest from ab_sdk.events import OrganizedFile, TorrentOrganized @@ -33,14 +33,14 @@ def make_plugin(options, handler=None): plugin, _ = create_plugin( load_candidate().load(), options, plugin_id="media-server-refresh" ) - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] - def record(request: httpx.Request) -> httpx.Response: + def record(request: httpx2.Request) -> httpx2.Response: requests.append(request) - return handler(request) if handler else httpx.Response(204) + return handler(request) if handler else httpx2.Response(204) - plugin.client_factory = lambda: httpx.AsyncClient( - transport=httpx.MockTransport(record) + plugin.client_factory = lambda: httpx2.AsyncClient( + transport=httpx2.MockTransport(record) ) return plugin, requests @@ -107,7 +107,7 @@ async def test_on_organized_burst_coalesces_into_one_refresh( async def test_refresh_http_error_returns_false(): plugin, requests = make_plugin( - {"url": "http://srv", "api_key": "k"}, handler=lambda r: httpx.Response(401) + {"url": "http://srv", "api_key": "k"}, handler=lambda r: httpx2.Response(401) ) assert await plugin.refresh() is False assert len(requests) == 1 @@ -115,7 +115,7 @@ async def test_refresh_http_error_returns_false(): async def test_on_organized_connection_error_logged_without_secret(caplog): def explode(request): - raise httpx.ConnectError("refused") + raise httpx2.ConnectError("refused") plugin, _ = make_plugin( {"url": "http://srv", "api_key": "s3cr3t-key", "delay": 0}, handler=explode @@ -140,17 +140,17 @@ async def test_teardown_pending_refresh_cancelled(): async def test_on_organized_during_refresh_request_refreshes_again(): plugin, _ = make_plugin({"url": "http://srv", "api_key": "k", "delay": 0}) - requests: list[httpx.Request] = [] + requests: list[httpx2.Request] = [] - async def handler(request: httpx.Request) -> httpx.Response: + async def handler(request: httpx2.Request) -> httpx2.Response: requests.append(request) if len(requests) == 1: # 刷新请求已发出、尚未返回时又有种子整理完成 await plugin.on_organized(organized(2)) - return httpx.Response(204) + return httpx2.Response(204) - plugin.client_factory = lambda: httpx.AsyncClient( - transport=httpx.MockTransport(handler) + plugin.client_factory = lambda: httpx2.AsyncClient( + transport=httpx2.MockTransport(handler) ) await plugin.on_organized(organized(1)) assert plugin._pending is not None diff --git a/backend/src/test/test_plugin_catalog.py b/backend/src/test/test_plugin_catalog.py index 9f691b41..0c9a920f 100644 --- a/backend/src/test/test_plugin_catalog.py +++ b/backend/src/test/test_plugin_catalog.py @@ -10,7 +10,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock -import httpx +import httpx2 import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import ( @@ -88,21 +88,21 @@ def make_installer(tmp_path, keypair, zip_bytes, *, entry=None, tamper_sig=False zip_sig = base64.b64encode(b"\x00" * 64).decode() if tamper_sig else sign(zip_bytes) requested: list[str] = [] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: url = str(request.url) requested.append(url) assert "/releases/download/plugins/" in url if url.endswith("catalog.json"): - return httpx.Response(200, content=catalog_bytes) + return httpx2.Response(200, content=catalog_bytes) if url.endswith("catalog.json.sig"): - return httpx.Response(200, text=sign(catalog_bytes)) + return httpx2.Response(200, text=sign(catalog_bytes)) if url.endswith(".zip"): - return httpx.Response(200, content=zip_bytes) + return httpx2.Response(200, content=zip_bytes) if url.endswith(".zip.sig"): - return httpx.Response(200, text=zip_sig) - return httpx.Response(404) + return httpx2.Response(200, text=zip_sig) + return httpx2.Response(404) - client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) return PluginInstaller( root=tmp_path / "plugins", pubkey_path=pubkey_path, @@ -192,7 +192,7 @@ class TestInstall: ): def sdk_too_new(plugin_dir: Path) -> None: manifest = plugin_dir / "plugin.toml" - manifest.write_text(manifest.read_text().replace(">=0.5", ">=9")) + manifest.write_text(manifest.read_text().replace(">=0.6", ">=9")) def with_native(plugin_dir: Path) -> None: (plugin_dir / "fast.so").write_bytes(b"\x7fELF") @@ -300,14 +300,14 @@ class TestReleaseScript: released = tmp_path / "release" - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: name = request.url.path.rsplit("/", 1)[-1] - return httpx.Response(200, content=(released / name).read_bytes()) + return httpx2.Response(200, content=(released / name).read_bytes()) installer = PluginInstaller( root=tmp_path / "plugins", pubkey_path=pubkey_path, - client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), app_version=app_version, ) [entry] = await installer.fetch_catalog() @@ -409,7 +409,7 @@ class TestPluginsApi: monkeypatch.setattr( plugins_api.PluginInstaller, "fetch_catalog", - AsyncMock(side_effect=httpx.ConnectError("down")), + AsyncMock(side_effect=httpx2.ConnectError("down")), ) assert authed_client.get("/api/v1/plugins/catalog").status_code == 502 diff --git a/backend/src/test/test_plugin_cli.py b/backend/src/test/test_plugin_cli.py index 46c13c60..349a7c01 100644 --- a/backend/src/test/test_plugin_cli.py +++ b/backend/src/test/test_plugin_cli.py @@ -44,7 +44,7 @@ def _break(plugin_dir: Path, problem: str) -> None: manifest = plugin_dir / "plugin.toml" if problem == "sdk": manifest.write_text( - manifest.read_text().replace('sdk = ">=0.5,<1"', 'sdk = ">=9"') + manifest.read_text().replace('sdk = ">=0.6,<1"', 'sdk = ">=9"') ) elif problem == "entry": (plugin_dir / "my_plugin" / "__init__.py").unlink() diff --git a/backend/src/test/test_plugin_web.py b/backend/src/test/test_plugin_web.py index e5755b31..d1cdda51 100644 --- a/backend/src/test/test_plugin_web.py +++ b/backend/src/test/test_plugin_web.py @@ -136,7 +136,7 @@ class TestApi: @pytest.mark.parametrize( "url", [ - # httpx 会把字面的 ../ 规范化掉,用百分号编码让它原样到达路由 + # httpx2 会把字面的 ../ 规范化掉,用百分号编码让它原样到达路由 "/api/v1/plugins/demo/web/%2e%2e%2fplugin.toml", # 绝对路径会让 root / path 直接变成该路径 "/api/v1/plugins/demo/web//etc/hosts", diff --git a/backend/src/test/test_qb_downloader.py b/backend/src/test/test_qb_downloader.py index b78e7cf0..98283944 100644 --- a/backend/src/test/test_qb_downloader.py +++ b/backend/src/test/test_qb_downloader.py @@ -9,7 +9,7 @@ import json import logging from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from module.downloader import AddResult, RenameOutcome @@ -120,7 +120,7 @@ def test_scheme_selection_matrix(host: str, ssl: bool, expected_prefix: str): class TestAuthClientCreation: - """auth() must create the httpx.AsyncClient with verify=False unconditionally.""" + """auth() must create the httpx2.AsyncClient with verify=False unconditionally.""" async def test_auth_creates_client_with_verify_false_when_ssl_true(self): """verify=False is used even when ssl=True (self-signed certs are common).""" @@ -144,7 +144,7 @@ class TestAuthClientCreation: pass with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient + "module.downloader.client.qb_downloader.httpx2.AsyncClient", _FakeClient ): result = await qb.auth() @@ -174,7 +174,7 @@ class TestAuthClientCreation: pass with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient + "module.downloader.client.qb_downloader.httpx2.AsyncClient", _FakeClient ): result = await qb.auth() @@ -185,7 +185,7 @@ class TestAuthClientCreation: """auth() sets connect timeout to 5.0 seconds.""" qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) - captured_timeouts: list[httpx.Timeout | None] = [] + captured_timeouts: list[httpx2.Timeout | None] = [] class _FakeClient: def __init__(self, **kwargs): @@ -201,7 +201,7 @@ class TestAuthClientCreation: pass with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient + "module.downloader.client.qb_downloader.httpx2.AsyncClient", _FakeClient ): await qb.auth() @@ -232,7 +232,7 @@ class TestAuthClientCreation: pass with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient + "module.downloader.client.qb_downloader.httpx2.AsyncClient", _FakeClient ): await qb.auth() @@ -264,7 +264,7 @@ class TestAuthSuccessFailure: mock_client.post = AsyncMock(return_value=mock_resp) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): result = await qb.auth() @@ -284,7 +284,7 @@ class TestAuthSuccessFailure: mock_client.post = AsyncMock(return_value=mock_resp) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): result = await qb.auth(retry=3) @@ -306,7 +306,7 @@ class TestAuthSuccessFailure: mock_client.post = AsyncMock(return_value=mock_resp) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): with patch( @@ -336,11 +336,11 @@ class TestAuthConnectErrorLogging: mock_client = AsyncMock() mock_client.post = AsyncMock( - side_effect=httpx.ConnectError("Connection refused") + side_effect=httpx2.ConnectError("Connection refused") ) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): with patch( @@ -370,10 +370,10 @@ class TestAuthConnectErrorLogging: assert qb.host.startswith("https://") mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=httpx.ConnectError("refused")) + mock_client.post = AsyncMock(side_effect=httpx2.ConnectError("refused")) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): with patch( @@ -398,11 +398,11 @@ class TestAuthConnectErrorLogging: mock_client = AsyncMock() mock_client.post = AsyncMock( - side_effect=httpx.ConnectError("Connection refused") + side_effect=httpx2.ConnectError("Connection refused") ) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): with patch( @@ -432,10 +432,10 @@ class TestAuthConnectErrorLogging: assert qb.host.startswith("http://") mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=httpx.ConnectError("refused")) + mock_client.post = AsyncMock(side_effect=httpx2.ConnectError("refused")) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): with patch( @@ -458,10 +458,10 @@ class TestAuthConnectErrorLogging: ) mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=httpx.ConnectError("refused")) + mock_client.post = AsyncMock(side_effect=httpx2.ConnectError("refused")) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): with patch( @@ -485,10 +485,10 @@ class TestAuthConnectErrorLogging: assert qb.host.startswith("http://") mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=httpx.ConnectError("refused")) + mock_client.post = AsyncMock(side_effect=httpx2.ConnectError("refused")) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): with patch( @@ -554,7 +554,7 @@ class TestAuthQb52Compat: mock_client.post = AsyncMock(return_value=mock_resp) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): result = await qb.auth() @@ -575,7 +575,7 @@ class TestAuthQb52Compat: with ( patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ), patch( @@ -600,7 +600,7 @@ class TestAuthQb52Compat: mock_client.post = AsyncMock(return_value=mock_resp) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): await qb.auth(retry=3) @@ -619,7 +619,7 @@ class TestAuthQb52Compat: mock_client.post = AsyncMock(return_value=mock_resp) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): await qb.auth(retry=3) @@ -632,11 +632,11 @@ class TestAuthQb52Compat: ) mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=httpx.ConnectError("boom")) + mock_client.post = AsyncMock(side_effect=httpx2.ConnectError("boom")) with ( patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ), patch( @@ -661,7 +661,7 @@ class TestAuthQb52Compat: mock_client.post = AsyncMock(return_value=mock_resp) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): result = await qb.auth() @@ -684,7 +684,7 @@ class TestAuthQb52Compat: mock_client.post = AsyncMock(return_value=mock_resp) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): result = await qb.auth(retry=3) @@ -706,7 +706,7 @@ class TestAuthQb52Compat: mock_client.post = AsyncMock(return_value=mock_resp) with patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ): result = await qb.auth(retry=3) @@ -729,7 +729,7 @@ class TestAuthQb52Compat: with ( patch( - "module.downloader.client.qb_downloader.httpx.AsyncClient", + "module.downloader.client.qb_downloader.httpx2.AsyncClient", return_value=mock_client, ), patch( @@ -1314,6 +1314,6 @@ class TestRenameVerifyContract: async def test_network_error_is_retryable(self): qb = self._make_qb([]) - qb._post = AsyncMock(side_effect=httpx.ConnectError("down")) + qb._post = AsyncMock(side_effect=httpx2.ConnectError("down")) result = await qb.torrents_rename_file("h", "old.mkv", "new.mkv") assert result.outcome is RenameOutcome.RETRYABLE_FAILURE diff --git a/backend/src/test/test_request_url.py b/backend/src/test/test_request_url.py index cffc1df0..af3dcab1 100644 --- a/backend/src/test/test_request_url.py +++ b/backend/src/test/test_request_url.py @@ -3,7 +3,7 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from module.network.request_url import ( @@ -44,7 +44,7 @@ class TestSharedClientLimits: async def test_client_follows_redirects(self): """Regression for #983: mikanime mirror returns 302 to the canonical - URL but httpx refuses to follow by default, so the RSS fetch fails.""" + URL but httpx2 refuses to follow by default, so the RSS fetch fails.""" client = await get_shared_client() assert client.follow_redirects is True @@ -83,7 +83,7 @@ class TestRetryWithReset: """get_url should retry a ConnectTimeout on the same shared client, without closing/resetting it -- other concurrent callers may still have in-flight requests on that connection pool.""" - import httpx + import httpx2 call_count = 0 @@ -91,7 +91,7 @@ class TestRetryWithReset: nonlocal call_count call_count += 1 if call_count == 1: - raise httpx.ConnectTimeout("Connection timed out") + raise httpx2.ConnectTimeout("Connection timed out") resp = MagicMock() resp.status_code = 200 resp.raise_for_status = MagicMock() @@ -122,12 +122,12 @@ class TestPoolTimeoutRecovery: 共享连接池被占满后永久 PoolTimeout。遇到 PoolTimeout 时应换新 client。""" async def _get_with_poisoned_pool(self): - import httpx + import httpx2 import module.network.request_url as mod old, new = AsyncMock(), AsyncMock() - old.get.side_effect = httpx.PoolTimeout("pool full") + old.get.side_effect = httpx2.PoolTimeout("pool full") resp = MagicMock(status_code=200, raise_for_status=MagicMock()) new.get.return_value = resp mod._shared_client = old @@ -135,7 +135,7 @@ class TestPoolTimeoutRecovery: try: with ( patch("module.network.request_url.asyncio.sleep", new=AsyncMock()), - patch("module.network.request_url.httpx.AsyncClient", return_value=new), + patch("module.network.request_url.httpx2.AsyncClient", return_value=new), ): async with RequestURL() as req: result = await req.get_url("https://example.com/x", retry=2) @@ -194,11 +194,11 @@ class TestRateLimit429: URL = "https://nyaa.si/download/1.torrent" @staticmethod - def _resp_429(headers: dict | None = None) -> httpx.Response: - return httpx.Response( + def _resp_429(headers: dict | None = None) -> httpx2.Response: + return httpx2.Response( 429, headers=headers or {}, - request=httpx.Request("GET", TestRateLimit429.URL), + request=httpx2.Request("GET", TestRateLimit429.URL), ) @staticmethod @@ -277,7 +277,7 @@ class TestRateLimit429: assert calls == 2 async def test_get_url_non_429_status_does_not_retry(self): - resp_404 = httpx.Response(404, request=httpx.Request("GET", self.URL)) + resp_404 = httpx2.Response(404, request=httpx2.Request("GET", self.URL)) result, calls, mock_sleep = await self._run([resp_404]) assert result is None assert calls == 1 diff --git a/backend/src/test/test_setup.py b/backend/src/test/test_setup.py index 96344df4..34063495 100644 --- a/backend/src/test/test_setup.py +++ b/backend/src/test/test_setup.py @@ -158,11 +158,11 @@ class TestSetupGuard: class TestTestDownloader: def test_private_ip_accepted(self, client, mock_first_run): """Issue #1001: Private IPs must not be rejected for downloader test.""" - import httpx + import httpx2 - with patch("module.api.setup.httpx.AsyncClient") as mock_client_cls: + with patch("module.api.setup.httpx2.AsyncClient") as mock_client_cls: mock_instance = AsyncMock() - mock_instance.get.side_effect = httpx.ConnectError("refused") + mock_instance.get.side_effect = httpx2.ConnectError("refused") mock_client_cls.return_value.__aenter__ = AsyncMock( return_value=mock_instance ) @@ -186,11 +186,11 @@ class TestTestDownloader: def test_loopback_ip_accepted(self, client, mock_first_run): """Issue #1001: Loopback IPs must not be rejected for downloader test.""" - import httpx + import httpx2 - with patch("module.api.setup.httpx.AsyncClient") as mock_client_cls: + with patch("module.api.setup.httpx2.AsyncClient") as mock_client_cls: mock_instance = AsyncMock() - mock_instance.get.side_effect = httpx.ConnectError("refused") + mock_instance.get.side_effect = httpx2.ConnectError("refused") mock_client_cls.return_value.__aenter__ = AsyncMock( return_value=mock_instance ) @@ -211,11 +211,11 @@ class TestTestDownloader: assert data["success"] is False def test_connection_timeout(self, client, mock_first_run): - import httpx + import httpx2 - with patch("module.api.setup.httpx.AsyncClient") as mock_client_cls: + with patch("module.api.setup.httpx2.AsyncClient") as mock_client_cls: mock_instance = AsyncMock() - mock_instance.get.side_effect = httpx.TimeoutException("timeout") + mock_instance.get.side_effect = httpx2.TimeoutException("timeout") mock_client_cls.return_value.__aenter__ = AsyncMock( return_value=mock_instance ) @@ -236,11 +236,11 @@ class TestTestDownloader: assert data["success"] is False def test_connection_refused(self, client, mock_first_run): - import httpx + import httpx2 - with patch("module.api.setup.httpx.AsyncClient") as mock_client_cls: + with patch("module.api.setup.httpx2.AsyncClient") as mock_client_cls: mock_instance = AsyncMock() - mock_instance.get.side_effect = httpx.ConnectError("refused") + mock_instance.get.side_effect = httpx2.ConnectError("refused") mock_client_cls.return_value.__aenter__ = AsyncMock( return_value=mock_instance ) @@ -327,7 +327,7 @@ class TestRequestValidation: class TestSetupComplete: """Issue: setup wizard must route config reload through AppContext, not - mutate settings.__dict__ directly (the shared httpx client, notifier, and + mutate settings.__dict__ directly (the shared httpx2 client, notifier, and scheduler must be rebuilt after first-run setup).""" @staticmethod @@ -537,7 +537,7 @@ class TestTestDownloaderHardening: else: mock_instance.get.return_value = get_resp mock_instance.post.return_value = login_resp - cls_patch = patch("module.api.setup.httpx.AsyncClient") + cls_patch = patch("module.api.setup.httpx2.AsyncClient") mock_cls = cls_patch.start() mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_instance) mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) diff --git a/backend/src/test/test_update.py b/backend/src/test/test_update.py index 780874ce..2ba78642 100644 --- a/backend/src/test/test_update.py +++ b/backend/src/test/test_update.py @@ -1,7 +1,7 @@ """在线自动更新测试:check / apply / rollback、boot_overlay 覆盖层、以及三个 端点的 path+verb+鉴权契约。 -网络与文件系统全部 mock:GitHub Release API 与下载走 httpx.MockTransport, +网络与文件系统全部 mock:GitHub Release API 与下载走 httpx2.MockTransport, 覆盖层落地走 tmp_path,进程退出被打桩以确保测试不会真的退出。 """ @@ -15,7 +15,7 @@ import zipfile from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -123,8 +123,8 @@ def _releases_payload(bundle_bytes: bytes, sha_text: str) -> list[dict]: ] -def _make_client(handler) -> httpx.AsyncClient: - return httpx.AsyncClient(transport=httpx.MockTransport(handler)) +def _make_client(handler) -> httpx2.AsyncClient: + return httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) def _seed_schema_db(path: Path, version: int) -> None: @@ -153,21 +153,21 @@ def _default_handler(bundle_bytes: bytes, sha_hex: str, sig_b64: str | None = No """路由 GitHub API 与下载 URL 到对应的响应。""" signature = sig_b64 if sig_b64 is not None else _sign_bytes(bundle_bytes) - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: url = str(request.url) if "api.github.com" in url and "/releases" in url: - return httpx.Response(200, json=_releases_payload(bundle_bytes, sha_hex)) + return httpx2.Response(200, json=_releases_payload(bundle_bytes, sha_hex)) if url.endswith("bundle.zip"): - return httpx.Response( + return httpx2.Response( 200, content=bundle_bytes, headers={"content-length": str(len(bundle_bytes))}, ) if url.endswith("bundle.sha256"): - return httpx.Response(200, text=sha_hex + "\n") + return httpx2.Response(200, text=sha_hex + "\n") if url.endswith("bundle.sig"): - return httpx.Response(200, text=signature + "\n") - return httpx.Response(404) + return httpx2.Response(200, text=signature + "\n") + return httpx2.Response(404) return handler @@ -253,7 +253,7 @@ class TestCheckUpdate: @pytest.mark.asyncio async def test_network_error_is_graceful(self, tmp_path): def handler(request): - raise httpx.ConnectError("boom") + raise httpx2.ConnectError("boom") up = Updater( root=tmp_path / "u", diff --git a/backend/uv.lock b/backend/uv.lock index a5318f67..3db93fbf 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -35,21 +35,21 @@ wheels = [ [[package]] name = "anthropic" -version = "0.116.0" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, { name = "docstring-parser" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/a2/d31f14e28d49bae983a3634e38dfb4b31c50110b5e403596c5c6a20b23f8/anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396", size = 949149, upload-time = "2026-07-02T19:08:10.534Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/37/5a2a4316df8babd28f096477a707cd75f240b4264c97fb128c32375105d6/anthropic-1.15.0.tar.gz", hash = "sha256:90caa9df83e2fc8bddb4ddeb83b7191bce255f635c03eae055deec521741df08", size = 1726101, upload-time = "2026-10-15T19:45:34.635Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/dd/2a1e81cf1b163acc340afc4ec74ed1d86f5eed1a809fabdeed3e0997b346/anthropic-0.116.0-py3-none-any.whl", hash = "sha256:6c0a7698e8d652455da3499978279bb2588c7264d0a35be3666009a4258c8256", size = 956896, upload-time = "2026-07-02T19:08:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/d1/65/54437e7edea49a3be9ef56f6b5965999bfdc52b9dadc9b448124ca51a594/anthropic-1.15.0-py3-none-any.whl", hash = "sha256:10189a7993c5c71a0a2d4bb80020ca6a2fd27679f51e627405da37251d15ff49", size = 2047755, upload-time = "2026-10-15T19:45:32.951Z" }, ] [[package]] @@ -148,8 +148,7 @@ dependencies = [ { name = "cryptography" }, { name = "fastapi" }, { name = "google-genai" }, - { name = "httpx", extra = ["socks"] }, - { name = "httpx-socks" }, + { name = "httpx2", extra = ["socks"] }, { name = "jinja2" }, { name = "mcp" }, { name = "openai" }, @@ -184,8 +183,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=50.0.2" }, { name = "fastapi", specifier = ">=0.109.0" }, { name = "google-genai", specifier = ">=2.10.0" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.25.0" }, - { name = "httpx-socks", specifier = ">=0.9.0" }, + { name = "httpx2", extras = ["socks"], specifier = ">=2.15" }, { name = "jinja2", specifier = ">=3.1.2" }, { name = "mcp", specifier = ">=1.8.0" }, { name = "openai", specifier = ">=1.54.3" }, @@ -216,14 +214,14 @@ dev = [ name = "autobangumi-sdk" source = { editable = "sdk" } dependencies = [ - { name = "httpx" }, + { name = "httpx2" }, { name = "packaging" }, { name = "pydantic" }, ] [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.25.0" }, + { name = "httpx2", specifier = ">=2.15" }, { name = "packaging", specifier = ">=26.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" }, @@ -512,15 +510,15 @@ wheels = [ [[package]] name = "google-auth" -version = "2.55.1" +version = "2.62.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/f3f4ac177c67bbee8fe8e88f2ab4f36af88c44a096e165c5217accf6e5d3/google_auth-2.55.1.tar.gz", hash = "sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1", size = 349527, upload-time = "2026-06-25T23:39:27.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/b9/4b2528f30114b106e3c3a7298ba38d85e682fd1f83fe7c4a5b77c7677082/google_auth-2.62.0.tar.gz", hash = "sha256:0bef0ce54bdf9ce226c5d66e4264413bd918141c31bbe49fb52eac882f513d69", size = 398180, upload-time = "2026-10-12T19:20:48.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a3/87fe9b7203ec2e56ea6a57c75e6d632eaf9487c7f7978cdad1d801bee0af/google_auth-2.62.0-py3-none-any.whl", hash = "sha256:4ff4319aeb4ad128409759d397a9fcafad126d0031d241cc0dd6b9a00b43e3f3", size = 267911, upload-time = "2026-10-12T19:20:46.355Z" }, ] [package.optional-dependencies] @@ -530,7 +528,7 @@ requests = [ [[package]] name = "google-genai" -version = "2.10.0" +version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -544,9 +542,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/fe/b796087493c3c55371aa58b9f264841ace5bfdf8c668cafa7afa33c44bec/google_genai-2.10.0.tar.gz", hash = "sha256:77912cd558cd7dfd5b75c25fd1c609e78d7954dde583331104022a46ea90f9ee", size = 600039, upload-time = "2026-06-24T01:33:18.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/69/6a3d3839ab56ec17d7923c8679b38955e286e0a9793df10ce6d13bc5f122/google_genai-2.31.0.tar.gz", hash = "sha256:71518537de2ba967a61960cba844691dca3dd4ff07726c01a6917d132213541e", size = 745679, upload-time = "2026-10-14T23:52:24.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/39/00bcfd94de255d24249401efff4f48d77bf6066b46447e519fa193c0c299/google_genai-2.10.0-py3-none-any.whl", hash = "sha256:d5350311567ae660c24cbc1752aee4b3d660f89c0106d2dcd2a69978c35afe1e", size = 957974, upload-time = "2026-06-24T01:33:16.296Z" }, + { url = "https://files.pythonhosted.org/packages/1c/64/1e9ab799830101ccbdcb67376a3f12b59e51f57643862999fd31af6938a1/google_genai-2.31.0-py3-none-any.whl", hash = "sha256:c87b9d495a24b09dedb29b852a201bea69ecd0b3f6c2561dc68f4055451b1b90", size = 1208245, upload-time = "2026-10-14T23:52:22.766Z" }, ] [[package]] @@ -594,6 +592,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/a9/11bc39967bb9dfaab0981ab4d49f519818acbee662a7e76d06813319c838/httpcore2-2.15.1.tar.gz", hash = "sha256:a94b6b3e6260e1a3f035915408d3dcd7dc8e844c2b544b642e227cf98a747af0", size = 69859, upload-time = "2026-10-19T20:14:23.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/e2b556e26fbc67716ab1dfc18832eb6fe196d88363f7094cb1da3d7c0d3e/httpcore2-2.15.1-py3-none-any.whl", hash = "sha256:730f603058c260a3288378217b464713350c23e3b8fe8bba753701054032d98a", size = 85055, upload-time = "2026-10-19T20:14:21.363Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -609,26 +620,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[package.optional-dependencies] -socks = [ - { name = "socksio" }, -] - -[[package]] -name = "httpx-socks" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpcore" }, - { name = "httpx" }, - { name = "python-socks" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/17/b4/1a5a0f67207a117ca554677ccde6f30679d4b5c10a1bf838b93c7644f468/httpx_socks-0.11.0.tar.gz", hash = "sha256:2e2de097d87dfc228dd36e3c5ae0588c836e48159f5996b33cef540497af9b32", size = 117971, upload-time = "2025-12-05T05:46:41.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/2a/78b08da3f2c8eb4dd31420d0a38ed4fd4cce272dbe6a8a0d154c0300002b/httpx_socks-0.11.0-py3-none-any.whl", hash = "sha256:8c28ad569ccf681b45437ea8465203cbc082206659b6f623e4ea509b1eb4e8a7", size = 13308, upload-time = "2025-12-05T05:46:40.193Z" }, -] - [[package]] name = "httpx-sse" version = "0.4.3" @@ -639,12 +630,42 @@ wheels = [ ] [[package]] -name = "idna" -version = "3.11" +name = "httpx2" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/85/07978f4afd168d6b408fa75c1dc13e3010653a81e182aa1b6d89ab94d9c6/httpx2-2.15.1.tar.gz", hash = "sha256:ff75485308fbe441e75b1218546e4e78d884df41e1a9741c1f79335ed8fcfef7", size = 101673, upload-time = "2026-10-19T20:14:24.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/7a/bf/56068f774620330c8330e4b4915fae889a5b2d4b52667b3b7c8f66c4a9ad/httpx2-2.15.1-py3-none-any.whl", hash = "sha256:75841cd83d16b417f7d6da8f2089ce8120806efdbf96af37d46c5129c49015be", size = 96411, upload-time = "2026-10-19T20:14:22.789Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -670,35 +691,65 @@ wheels = [ [[package]] name = "jiter" -version = "0.12.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" }, - { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" }, - { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" }, - { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" }, - { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" }, - { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" }, - { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" }, - { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" }, - { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, ] [[package]] @@ -906,21 +957,31 @@ wheels = [ [[package]] name = "openai" -version = "2.15.0" +version = "3.35.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, - { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/4c/609b16f2a6e9a99f27a82ce93ff75868e7cb364fe6daff50cc0c1d620772/openai-3.35.1.tar.gz", hash = "sha256:b33e6561b90586e450def8a388e7d364755325da6884fc71348be5542e043bd0", size = 2250942, upload-time = "2026-10-18T01:06:38.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/22/7b/6d7e70f0762d1b286051bd7cb2fd0c364c1830d0f8826738673aeec05c2b/openai-3.35.1-py3-none-any.whl", hash = "sha256:264fd0fafc889021f526b7c1e5a2df5ca3e8dea874738ecd31da666434f25967", size = 2280230, upload-time = "2026-10-18T01:06:36.16Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, ] [[package]] @@ -1174,15 +1235,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, ] -[[package]] -name = "python-socks" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/07/cfdd6a846ac859e513b4e68bb6c669a90a74d89d8d405516fba7fc9c6f0c/python_socks-2.8.0.tar.gz", hash = "sha256:340f82778b20a290bdd538ee47492978d603dff7826aaf2ce362d21ad9ee6f1b", size = 273130, upload-time = "2025-12-09T12:17:05.433Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/10/e2b575faa32d1d32e5e6041fc64794fa9f09526852a06b25353b66f52cae/python_socks-2.8.0-py3-none-any.whl", hash = "sha256:57c24b416569ccea493a101d38b0c82ed54be603aa50b6afbe64c46e4a4e4315", size = 55075, upload-time = "2025-12-09T12:17:03.269Z" }, -] - [[package]] name = "pywin32" version = "311" @@ -1426,15 +1478,12 @@ wheels = [ ] [[package]] -name = "tqdm" -version = "4.67.1" +name = "truststore" +version = "0.10.5" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, ] [[package]] diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md index be3ee638..6d870388 100644 --- a/docs/dev/plugins.md +++ b/docs/dev/plugins.md @@ -11,7 +11,7 @@ 1. 安装 SDK 与命令行。轮子随每个 4.0 beta / 正式版发布在 [GitHub Releases](https://github.com/EstrellaXD/Auto_Bangumi/releases) 的附件中(文件名 `autobangumi_sdk--py3-none-any.whl`),不发布到 PyPI。详见 [获取 SDK](/dev/plugins/sdk)。 ```bash - uv tool install ./autobangumi_sdk-0.5.0-py3-none-any.whl + uv tool install ./autobangumi_sdk-0.6.0-py3-none-any.whl ``` 2. 生成骨架并测试。骨架的 `pyproject.toml` 依赖 `autobangumi-sdk`,而 PyPI 上没有这个包,所以先用 `uv add` 让它指向下载的轮子: @@ -19,7 +19,7 @@ ```bash ab-plugin new my-rename --kind rename # 也可选 notifier、search cd my-rename - uv add ../autobangumi_sdk-0.5.0-py3-none-any.whl # 换成轮子的实际路径 + uv add ../autobangumi_sdk-0.6.0-py3-none-any.whl # 换成轮子的实际路径 uv run pytest # 骨架自带契约测试 ``` diff --git a/docs/dev/plugins/cli.md b/docs/dev/plugins/cli.md index 34471b27..ee299dae 100644 --- a/docs/dev/plugins/cli.md +++ b/docs/dev/plugins/cli.md @@ -3,8 +3,8 @@ `ab-plugin` 随 `autobangumi-sdk` 轮子安装,只依赖 `ab_sdk`,不需要安装 AutoBangumi。 ```bash -uv tool install ./autobangumi_sdk-0.5.0-py3-none-any.whl -# 或在项目里:uv add ./autobangumi_sdk-0.5.0-py3-none-any.whl +uv tool install ./autobangumi_sdk-0.6.0-py3-none-any.whl +# 或在项目里:uv add ./autobangumi_sdk-0.6.0-py3-none-any.whl ``` 轮子在 4.0 beta / 正式版的 GitHub Release 附件中,不发布到 PyPI,见 [获取 SDK](/dev/plugins/sdk)。契约测试需要 pytest:`new` 生成的 `pyproject.toml` 已在 `dev` 依赖组里带上 pytest;自己建的项目用 `uv add --dev pytest`(或安装 `autobangumi-sdk[test]`)。 @@ -65,7 +65,7 @@ ab-plugin dev [path] [--config-dir config] ```bash ab-plugin new my-notify --kind notifier cd my-notify -uv add ../autobangumi_sdk-0.5.0-py3-none-any.whl # 只需一次 +uv add ../autobangumi_sdk-0.6.0-py3-none-any.whl # 只需一次 uv run pytest # 契约测试 ab-plugin dev . --config-dir /path/to/autobangumi/config # 链接并开启热重载,重启 AB 一次 # 改代码 → AB 自动重载 → 在 WebUI 里验证 diff --git a/docs/dev/plugins/points/llm-provider.md b/docs/dev/plugins/points/llm-provider.md index e84ed417..e13c0dea 100644 --- a/docs/dev/plugins/points/llm-provider.md +++ b/docs/dev/plugins/points/llm-provider.md @@ -27,6 +27,7 @@ class MyPlugin(Plugin): ## 要点 - `ProviderInfo` 驱动前端表单和提供商列表:`auth_kind` 为 `api_key`、`oauth` 或 `device_code`。 -- 适配器通过 `AdapterContext` 拿到模型名、密钥、`base_url`、超时和代理感知的 httpx 客户端工厂,不读全局配置。 +- 适配器通过 `AdapterContext` 拿到模型名、密钥、`base_url`、超时和代理感知的 httpx2 客户端工厂,不读全局配置。 +- 工厂返回 `httpx2.AsyncClient`(SDK 0.6 起,此前为 `httpx`);捕获网络异常时用 `httpx2.HTTPError` 等,`httpx` 的异常类捕获不到它抛出的错误。 - 订阅类提供商(`oauth` / `device_code`)实现认证流程钩子(`AuthChallenge`、`TokenSet`)。凭据失效且刷新失败时抛出 `AuthExpiredError`,AB 会熔断该提供商并发 `llm_auth_failure` 事件。 - 现有的 `plugin.json` 格式 LLM 插件(`llm-plugins` 发布)继续可用;新插件使用 `plugin.toml` 与本扩展点。 diff --git a/docs/dev/plugins/sdk.md b/docs/dev/plugins/sdk.md index b5743896..b519a47f 100644 --- a/docs/dev/plugins/sdk.md +++ b/docs/dev/plugins/sdk.md @@ -7,16 +7,16 @@ | `autobangumi_sdk--py3-none-any.whl` | `ab_sdk` 包与 `ab-plugin` 命令行 | | `autobangumi-plugin-skill-.zip` | 给 AI 编码助手使用的插件开发 skill | -轮子文件名里是 SDK 版本(`ab_sdk.SDK_VERSION`,如 `0.5.0`),不是 AB 的版本号。请从目标 AB 版本的 Release 下载轮子,两者的 SDK 版本一致。插件清单里的 `sdk` 范围(如 `">=0.5,<1"`)必须包含这个版本。 +轮子文件名里是 SDK 版本(`ab_sdk.SDK_VERSION`,如 `0.6.0`),不是 AB 的版本号。请从目标 AB 版本的 Release 下载轮子,两者的 SDK 版本一致。插件清单里的 `sdk` 范围(如 `">=0.5,<1"`)必须包含这个版本。 ## 安装轮子 -轮子要求 Python 3.13 或更高,依赖 `pydantic`、`httpx` 与 `packaging`,不需要安装 AutoBangumi。 +轮子要求 Python 3.13 或更高,依赖 `pydantic`、`httpx2` 与 `packaging`,不需要安装 AutoBangumi。 只用命令行时,装成全局工具: ```bash -uv tool install ./autobangumi_sdk-0.5.0-py3-none-any.whl +uv tool install ./autobangumi_sdk-0.6.0-py3-none-any.whl ab-plugin --help ``` @@ -24,7 +24,7 @@ ab-plugin --help ```bash cd my-plugin -uv add ../autobangumi_sdk-0.5.0-py3-none-any.whl +uv add ../autobangumi_sdk-0.6.0-py3-none-any.whl uv run pytest ``` diff --git a/docs/en/dev/plugins.md b/docs/en/dev/plugins.md index 4e973709..b51d13a1 100644 --- a/docs/en/dev/plugins.md +++ b/docs/en/dev/plugins.md @@ -11,7 +11,7 @@ A plugin is a Python package, with optional frontend components. It runs inside 1. Install the SDK and the command line. The wheel is an attachment of each 4.0 beta and stable release on [GitHub Releases](https://github.com/EstrellaXD/Auto_Bangumi/releases). The file name is `autobangumi_sdk--py3-none-any.whl`. The wheel is not on PyPI. Refer to [Get the SDK](/en/dev/plugins/sdk). ```bash - uv tool install ./autobangumi_sdk-0.5.0-py3-none-any.whl + uv tool install ./autobangumi_sdk-0.6.0-py3-none-any.whl ``` 2. Create a skeleton and run its tests. The `pyproject.toml` of the skeleton needs `autobangumi-sdk`. This package is not on PyPI. Thus, first use `uv add` to point the dependency to the wheel that you downloaded: @@ -19,7 +19,7 @@ A plugin is a Python package, with optional frontend components. It runs inside ```bash ab-plugin new my-rename --kind rename # or notifier, search cd my-rename - uv add ../autobangumi_sdk-0.5.0-py3-none-any.whl # use the real path of the wheel + uv add ../autobangumi_sdk-0.6.0-py3-none-any.whl # use the real path of the wheel uv run pytest # the skeleton includes a contract test ``` diff --git a/docs/en/dev/plugins/cli.md b/docs/en/dev/plugins/cli.md index 65451080..7aa80128 100644 --- a/docs/en/dev/plugins/cli.md +++ b/docs/en/dev/plugins/cli.md @@ -3,8 +3,8 @@ `ab-plugin` is installed with the `autobangumi-sdk` wheel. It depends only on `ab_sdk`. You do not need to install AutoBangumi. ```bash -uv tool install ./autobangumi_sdk-0.5.0-py3-none-any.whl -# or in a project: uv add ./autobangumi_sdk-0.5.0-py3-none-any.whl +uv tool install ./autobangumi_sdk-0.6.0-py3-none-any.whl +# or in a project: uv add ./autobangumi_sdk-0.6.0-py3-none-any.whl ``` The wheel is an attachment of the 4.0 beta and stable GitHub releases. It is not on PyPI. Refer to [Get the SDK](/en/dev/plugins/sdk). The contract tests need pytest. The `pyproject.toml` that `new` creates already has pytest in its `dev` dependency group. For a project that you make yourself, use `uv add --dev pytest` (or install `autobangumi-sdk[test]`). @@ -65,7 +65,7 @@ ab-plugin dev [path] [--config-dir config] ```bash ab-plugin new my-notify --kind notifier cd my-notify -uv add ../autobangumi_sdk-0.5.0-py3-none-any.whl # one time only +uv add ../autobangumi_sdk-0.6.0-py3-none-any.whl # one time only uv run pytest # contract tests ab-plugin dev . --config-dir /path/to/autobangumi/config # link and enable hot reload; restart AB one time # change code -> AB reloads -> check in the WebUI diff --git a/docs/en/dev/plugins/points/llm-provider.md b/docs/en/dev/plugins/points/llm-provider.md index 2055e81e..cfc11d57 100644 --- a/docs/en/dev/plugins/points/llm-provider.md +++ b/docs/en/dev/plugins/points/llm-provider.md @@ -27,6 +27,7 @@ class MyPlugin(Plugin): ## Notes - `ProviderInfo` drives the frontend form and the provider list. `auth_kind` is `api_key`, `oauth` or `device_code`. -- An adapter gets the model name, key, `base_url`, timeout and a proxy-aware httpx client factory through `AdapterContext`. It does not read the global configuration. +- An adapter gets the model name, key, `base_url`, timeout and a proxy-aware httpx2 client factory through `AdapterContext`. It does not read the global configuration. +- The factory returns an `httpx2.AsyncClient` (since SDK 0.6; before, it was `httpx`). To catch network errors, use `httpx2.HTTPError` and related classes. The `httpx` exception classes do not catch its errors. - A subscription-type provider (`oauth` / `device_code`) implements the auth flow hooks (`AuthChallenge`, `TokenSet`). When the credentials are no longer valid and the refresh fails, raise `AuthExpiredError`. AB then trips the breaker for this provider and sends an `llm_auth_failure` event. - Existing LLM plugins in the `plugin.json` format (released as `llm-plugins`) still work. New plugins use `plugin.toml` and this extension point. diff --git a/docs/en/dev/plugins/sdk.md b/docs/en/dev/plugins/sdk.md index 09900d13..d687724e 100644 --- a/docs/en/dev/plugins/sdk.md +++ b/docs/en/dev/plugins/sdk.md @@ -7,16 +7,16 @@ The plugin SDK is not on PyPI. Each 4.0 beta and stable [GitHub release](https:/ | `autobangumi_sdk--py3-none-any.whl` | The `ab_sdk` package and the `ab-plugin` command | | `autobangumi-plugin-skill-.zip` | A plugin-development skill for AI coding assistants | -The wheel file name contains the SDK version (`ab_sdk.SDK_VERSION`, for example `0.5.0`). It does not contain the AB version. Download the wheel from the release of your target AB version, so that the two SDK versions are the same. The `sdk` range in the plugin manifest (for example `">=0.5,<1"`) must include this version. +The wheel file name contains the SDK version (`ab_sdk.SDK_VERSION`, for example `0.6.0`). It does not contain the AB version. Download the wheel from the release of your target AB version, so that the two SDK versions are the same. The `sdk` range in the plugin manifest (for example `">=0.5,<1"`) must include this version. ## Install the wheel -The wheel needs Python 3.13 or later. It depends on `pydantic`, `httpx` and `packaging`. You do not need to install AutoBangumi. +The wheel needs Python 3.13 or later. It depends on `pydantic`, `httpx2` and `packaging`. You do not need to install AutoBangumi. To use only the command line, install the wheel as a global tool: ```bash -uv tool install ./autobangumi_sdk-0.5.0-py3-none-any.whl +uv tool install ./autobangumi_sdk-0.6.0-py3-none-any.whl ab-plugin --help ``` @@ -24,7 +24,7 @@ To write a plugin and run its tests, add the wheel as a dependency of the plugin ```bash cd my-plugin -uv add ../autobangumi_sdk-0.5.0-py3-none-any.whl +uv add ../autobangumi_sdk-0.6.0-py3-none-any.whl uv run pytest ```