✨ feat(http_utils): 添加可选的 accept_status_codes 参数以支持自定义状态码处理 (#2096)

- 在 AsyncHttpx 类中,新增 accept_status_codes 参数,允许用户指定哪些 HTTP 状态码不视为错误。
- 更新了单次请求的逻辑,以便在响应状态码符合 accept_status_codes 时不调用 raise_for_status 方法。

✨ feat(message): 增加随机失败提示消息功能

- 在 MessageUtils 类中,添加可爱风格的失败提示消息列表和获取随机消息的方法。

✨ feat(github_utils): 引入动态查询外部插件分组的功能

- 在 AliyunFileInfo 类中,新增 list_group_repositories 方法以列出分组下的所有仓库,并实现通过仓库名称获取仓库ID的逻辑。

✨ feat(virtual_env_package_manager): 增加清理 requirements 文件的功能

- 新增 _clean_requirements_file 方法,清理 requirements 文件中的非ASCII注释,避免 Windows 上 pip 使用 GBK 编码读取 UTF-8 文件时出错。

✨ feat(repo_utils): 优化阿里云仓库 URL 处理逻辑

- 更新 prepare_aliyun_url 方法以支持指定分组名称,并新增 get_aliyun_group_for_repo 方法以获取仓库所属的阿里云分组名。
This commit is contained in:
HibiKier
2026-02-06 16:45:48 +08:00
committed by GitHub
parent b95acce800
commit 5c067bcf04
7 changed files with 368 additions and 22 deletions
+3
View File
@@ -74,3 +74,6 @@ ALIYUN_REPO_MAPPING = {
"zhenxun_bot": "4957428", "zhenxun_bot": "4957428",
} }
"""阿里云仓库ID映射""" """阿里云仓库ID映射"""
ALIYUN_EXTERNAL_PLUGIN_GROUPS: list[str] = ["zhenxun_plugins"]
"""阿里云外部插件分组列表,用于动态查询第三方插件仓库"""
+183 -13
View File
@@ -1,7 +1,7 @@
import base64 import base64
import contextlib import contextlib
import sys import sys
from typing import Protocol from typing import ClassVar, Protocol
from aiocache import cached from aiocache import cached
from alibabacloud_devops20210625 import models as devops_20210625_models from alibabacloud_devops20210625 import models as devops_20210625_models
@@ -20,6 +20,7 @@ else:
from .const import ( from .const import (
ALIYUN_ENDPOINT, ALIYUN_ENDPOINT,
ALIYUN_EXTERNAL_PLUGIN_GROUPS,
ALIYUN_ORG_ID, ALIYUN_ORG_ID,
ALIYUN_REGION, ALIYUN_REGION,
ALIYUN_REPO_MAPPING, ALIYUN_REPO_MAPPING,
@@ -317,6 +318,9 @@ class AliyunFileInfo:
repository_id: str repository_id: str
"""仓库ID""" """仓库ID"""
# 动态仓库ID缓存: {repo_name: repository_id}
_dynamic_repo_cache: ClassVar[dict[str, str]] = {}
@classmethod @classmethod
async def get_client(cls) -> devops20210625Client: async def get_client(cls) -> devops20210625Client:
"""获取阿里云客户端""" """获取阿里云客户端"""
@@ -331,6 +335,179 @@ class AliyunFileInfo:
return devops20210625Client(config) return devops20210625Client(config)
@classmethod
@cached(ttl=CACHED_API_TTL)
async def list_group_repositories(cls, group_path: str) -> list[dict]:
"""列出分组下的所有仓库
参数:
group_path: 分组路径,如 "zhenxun_plugins"
返回:
list[dict]: 仓库信息列表,每个元素包含 id, name, path 等
"""
try:
client = await cls.get_client()
result = []
page = 1
per_page = 100
while True:
request = devops_20210625_models.ListRepositoriesRequest(
organization_id=ALIYUN_ORG_ID,
access_token=base64.b64decode(
RDC_access_token_encrypted.encode()
).decode(),
page=page,
per_page=per_page,
)
runtime = util_models.RuntimeOptions()
headers = {}
response = await client.list_repositories_with_options_async(
request, headers, runtime
)
if response and response.body:
if not response.body.success:
raise ValueError(
f"阿里云请求失败: {response.body.error_code} - "
f"{response.body.error_message}"
)
repos = response.body.result or []
if not repos:
break
for repo in repos:
repo_dict = repo.to_map()
# 尝试多种可能的字段名
path_with_ns = (
repo_dict.get("pathWithNamespace")
or repo_dict.get("path_with_namespace")
or repo_dict.get("path")
or ""
)
# pathWithNamespace 格式: {org_id}/{group_path}/{repo_name}
# 只保留属于该分组的仓库
if f"/{group_path}/" in path_with_ns:
result.append(repo_dict)
# 同时更新缓存
repo_name = repo_dict.get("name", "")
# 注意:阿里云API返回的ID字段是 "Id" (大写I)
repo_id = str(
repo_dict.get("Id") or repo_dict.get("id") or ""
)
if repo_name and repo_id:
cls._dynamic_repo_cache[repo_name] = repo_id
# 如果返回的数量小于请求的数量,说明已经没有更多数据
if len(repos) < per_page:
break
page += 1
else:
break
return result
except Exception as e:
raise ValueError(f"获取仓库列表失败: {e}")
@classmethod
async def get_repository_id(cls, repo_name: str) -> str | None:
"""通过仓库名称获取仓库ID
优先从静态映射中查找,然后从缓存中查找,最后从API动态查询
参数:
repo_name: 仓库名称
返回:
str | None: 仓库ID,未找到返回 None
"""
# 1. 先从静态映射中查找
if repo_id := ALIYUN_REPO_MAPPING.get(repo_name):
return repo_id
# 2. 从动态缓存中查找
if repo_id := cls._dynamic_repo_cache.get(repo_name):
return repo_id
# 3. 从外部插件分组中动态查询
for group_path in ALIYUN_EXTERNAL_PLUGIN_GROUPS:
try:
repos = await cls.list_group_repositories(group_path)
for repo in repos:
if repo.get("name") == repo_name:
# 注意:阿里云API返回的ID字段是 "Id" (大写I)
repo_id = str(repo.get("Id") or repo.get("id") or "")
if repo_id:
cls._dynamic_repo_cache[repo_name] = repo_id
return repo_id
except Exception:
continue
return None
@classmethod
async def debug_list_all_repositories(cls) -> list[dict]:
"""调试方法:列出组织下的所有仓库信息
返回:
list[dict]: 所有仓库信息列表
"""
try:
client = await cls.get_client()
result = []
request = devops_20210625_models.ListRepositoriesRequest(
organization_id=ALIYUN_ORG_ID,
access_token=base64.b64decode(
RDC_access_token_encrypted.encode()
).decode(),
page=1,
per_page=100,
)
runtime = util_models.RuntimeOptions()
headers = {}
response = await client.list_repositories_with_options_async(
request, headers, runtime
)
if response and response.body and response.body.result:
for repo in response.body.result:
repo_dict = repo.to_map()
result.append(repo_dict)
# 同时填充缓存
repo_name = repo_dict.get("name", "")
repo_id = str(repo_dict.get("Id") or repo_dict.get("id") or "")
if repo_name and repo_id:
cls._dynamic_repo_cache[repo_name] = repo_id
return result
except Exception as e:
raise ValueError(f"列出仓库失败: {e}")
@classmethod
async def _get_repository_id_or_raise(cls, repo: str) -> str:
"""获取仓库ID,如果未找到则抛出异常
参数:
repo: 仓库名称
返回:
str: 仓库ID
异常:
ValueError: 未找到仓库
"""
repository_id = await cls.get_repository_id(repo)
if not repository_id:
raise ValueError(f"未找到仓库 {repo} 对应的阿里云仓库ID")
return repository_id
@classmethod @classmethod
async def get_file_content( async def get_file_content(
cls, file_path: str, repo: str, ref: str = "main" cls, file_path: str, repo: str, ref: str = "main"
@@ -346,9 +523,7 @@ class AliyunFileInfo:
str: 文件内容 str: 文件内容
""" """
try: try:
repository_id = ALIYUN_REPO_MAPPING.get(repo) repository_id = await cls._get_repository_id_or_raise(repo)
if not repository_id:
raise ValueError(f"未找到仓库 {repo} 对应的阿里云仓库ID")
client = await cls.get_client() client = await cls.get_client()
@@ -406,9 +581,7 @@ class AliyunFileInfo:
list[AliyunTree]: 仓库树信息列表 list[AliyunTree]: 仓库树信息列表
""" """
try: try:
repository_id = ALIYUN_REPO_MAPPING.get(repo) repository_id = await cls._get_repository_id_or_raise(repo)
if not repository_id:
raise ValueError(f"未找到仓库 {repo} 对应的阿里云仓库ID")
client = await cls.get_client() client = await cls.get_client()
@@ -452,9 +625,7 @@ class AliyunFileInfo:
commit: 最新提交信息 commit: 最新提交信息
""" """
try: try:
repository_id = ALIYUN_REPO_MAPPING.get(repo) repository_id = await cls._get_repository_id_or_raise(repo)
if not repository_id:
raise ValueError(f"未找到仓库 {repo} 对应的阿里云仓库ID")
client = await cls.get_client() client = await cls.get_client()
@@ -498,9 +669,8 @@ class AliyunFileInfo:
@classmethod @classmethod
async def parse_repo_info(cls, repo: str) -> list[str]: async def parse_repo_info(cls, repo: str) -> list[str]:
"""解析仓库信息获取仓库树""" """解析仓库信息获取仓库树"""
repository_id = ALIYUN_REPO_MAPPING.get(repo) # 验证仓库存在
if not repository_id: await cls._get_repository_id_or_raise(repo)
raise ValueError(f"未找到仓库 {repo} 对应的阿里云仓库ID")
tree_list = await cls.get_repository_tree( tree_list = await cls.get_repository_tree(
repo=repo, repo=repo,
+25 -3
View File
@@ -219,8 +219,12 @@ class AsyncHttpx:
) -> Response: ) -> Response:
""" """
执行单次HTTP请求的私有方法,内置了默认的重试逻辑。 执行单次HTTP请求的私有方法,内置了默认的重试逻辑。
accept_status_codes: 若提供,这些状态码不视为错误,不调用 raise_for_status。
""" """
client_kwargs, request_kwargs = cls._split_kwargs(kwargs) client_kwargs, request_kwargs = cls._split_kwargs(kwargs)
accept_status_codes: Sequence[int] | None = request_kwargs.pop(
"accept_status_codes", None
)
async with cls._get_active_client_context( async with cls._get_active_client_context(
client=client, **client_kwargs client=client, **client_kwargs
@@ -228,7 +232,11 @@ class AsyncHttpx:
response = await cls()._execute_request_inner( response = await cls()._execute_request_inner(
active_client, method, url, **request_kwargs active_client, method, url, **request_kwargs
) )
response.raise_for_status() if (
accept_status_codes is None
or response.status_code not in accept_status_codes
):
response.raise_for_status()
return response return response
@classmethod @classmethod
@@ -292,6 +300,7 @@ class AsyncHttpx:
*, *,
follow_redirects: bool = True, follow_redirects: bool = True,
check_status_code: int | None = None, check_status_code: int | None = None,
accept_status_codes: Sequence[int] | None = None,
client: AsyncClient | None = None, client: AsyncClient | None = None,
**kwargs, **kwargs,
) -> Response: ) -> Response:
@@ -301,6 +310,8 @@ class AsyncHttpx:
url: 单个请求 URL 或一个 URL 列表。 url: 单个请求 URL 或一个 URL 列表。
follow_redirects: 是否跟随重定向。 follow_redirects: 是否跟随重定向。
check_status_code: (可选) 若提供,将检查响应状态码是否匹配,否则抛出异常。 check_status_code: (可选) 若提供,将检查响应状态码是否匹配,否则抛出异常。
accept_status_codes: (可选) 这些状态码不视为错误,
如 (302,) 用于接受重定向响应。
client: (可选) 指定一个活动的HTTP客户端实例。若提供,则忽略 client: (可选) 指定一个活动的HTTP客户端实例。若提供,则忽略
`**kwargs`中的客户端配置。 `**kwargs`中的客户端配置。
**kwargs: 其他所有传递给 httpx.get 的参数 (如 `params`, `headers`, **kwargs: 其他所有传递给 httpx.get 的参数 (如 `params`, `headers`,
@@ -316,6 +327,8 @@ class AsyncHttpx:
async def worker(current_url: str, **worker_kwargs) -> Response: async def worker(current_url: str, **worker_kwargs) -> Response:
logger.info(f"开始获取 {current_url}..", "AsyncHttpx:get") logger.info(f"开始获取 {current_url}..", "AsyncHttpx:get")
if accept_status_codes is not None:
worker_kwargs["accept_status_codes"] = accept_status_codes
response = await cls._single_request( response = await cls._single_request(
"GET", current_url, follow_redirects=follow_redirects, **worker_kwargs "GET", current_url, follow_redirects=follow_redirects, **worker_kwargs
) )
@@ -342,11 +355,20 @@ class AsyncHttpx:
@classmethod @classmethod
async def post( async def post(
cls, url: str | list[str], *, client: AsyncClient | None = None, **kwargs cls,
url: str | list[str],
*,
accept_status_codes: Sequence[int] | None = None,
client: AsyncClient | None = None,
**kwargs,
) -> Response: ) -> Response:
"""发送 POST 请求,并返回第一个成功的响应。""" """发送 POST 请求,并返回第一个成功的响应。
accept_status_codes: (可选) 这些状态码不视为错误,如 (302,) 用于接受重定向响应。
"""
async def worker(current_url: str, **worker_kwargs) -> Response: async def worker(current_url: str, **worker_kwargs) -> Response:
if accept_status_codes is not None:
worker_kwargs["accept_status_codes"] = accept_status_codes
return await cls._single_request("POST", current_url, **worker_kwargs) return await cls._single_request("POST", current_url, **worker_kwargs)
return await cls._execute_with_fallbacks(url, worker, client=client, **kwargs) return await cls._execute_with_fallbacks(url, worker, client=client, **kwargs)
@@ -125,6 +125,40 @@ class VirtualEnvPackageManager:
logger.error(f"更新虚拟环境包指令执行失败: {e.stderr}.", LOG_COMMAND) logger.error(f"更新虚拟环境包指令执行失败: {e.stderr}.", LOG_COMMAND)
return e.stderr return e.stderr
@staticmethod
def _clean_requirements_file(file_path: Path) -> None:
"""清理 requirements 文件中的非ASCII注释
防止 Windows 上 pip 使用 GBK 编码读取 UTF-8 文件时出错
参数:
file_path: requirements 文件路径
"""
try:
content = file_path.read_text(encoding="utf-8")
lines = content.splitlines()
cleaned_lines = []
for line in lines:
stripped = line.strip()
# 跳过空行
if not stripped:
continue
# 如果是注释行且包含非ASCII字符,跳过
if stripped.startswith("#"):
try:
stripped.encode("ascii")
except UnicodeEncodeError:
continue
cleaned_lines.append(line)
# 写回文件
file_path.write_text(
"\n".join(cleaned_lines) + "\n" if cleaned_lines else "",
encoding="utf-8",
)
except Exception:
# 如果清理失败,忽略错误继续安装
pass
@classmethod @classmethod
async def install_requirement(cls, requirement_file: Path): async def install_requirement(cls, requirement_file: Path):
"""安装依赖文件 """安装依赖文件
@@ -137,6 +171,8 @@ class VirtualEnvPackageManager:
""" """
if not requirement_file.exists(): if not requirement_file.exists():
raise FileNotFoundError(f"依赖文件 {requirement_file} 不存在", LOG_COMMAND) raise FileNotFoundError(f"依赖文件 {requirement_file} 不存在", LOG_COMMAND)
# 清理 requirements 文件中的非ASCII注释,防止 Windows GBK 编码问题
cls._clean_requirements_file(requirement_file)
try: try:
command = cls.__get_command() command = cls.__get_command()
command.append("install") command.append("install")
+39
View File
@@ -1,6 +1,8 @@
import base64 import base64
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
import random
from typing import ClassVar
import nonebot import nonebot
from nonebot.adapters.onebot.v11 import Message, MessageSegment from nonebot.adapters.onebot.v11 import Message, MessageSegment
@@ -48,6 +50,43 @@ class Config(BaseModel):
class MessageUtils: class MessageUtils:
# 可爱风格的失败提示消息列表
FAILURE_MESSAGES: ClassVar[list[str]] = [
"出了点小问题,待会再试试吧~ (´・ω・`)",
"哎呀,失败了呢 QAQ",
"好像哪里不对劲... (・∀・;)",
"emm...出错了 (;′⌒`)",
"失败了,但问题不大!╮(╯▽╰)╭",
"搞砸了...下次一定行 (๑•̀ㅂ•́)و✧",
"这次没成功,再来一次?",
"出错啦,让我缓缓... (´-ω-`)",
"翻车了,容我想想 ( ˘•ω•˘ )",
"不太顺利呢,稍后再试吧 (´;ω;`)",
"失败了...不过没关系啦 (・ω<)☆",
"呃,出了点状况 Σ(っ°Д°;)っ",
"没搞定,但别灰心~",
"坏掉了...等等再试试看 (>_<)",
"这次不太行,下次加油!(ง •_•)ง",
]
@classmethod
def get_failure_message(cls) -> str:
"""获取随机失败提示消息
返回:
str: 随机的可爱失败提示消息
"""
return random.choice(cls.FAILURE_MESSAGES)
@classmethod
def build_failure_message(cls) -> UniMessage:
"""构造随机失败提示消息
返回:
UniMessage: 构造完成的失败提示消息
"""
return cls.build_message(cls.get_failure_message())
@classmethod @classmethod
def __build_message( def __build_message(
cls, msg_list: list[MESSAGE_TYPE], format_args: dict | None = None cls, msg_list: list[MESSAGE_TYPE], format_args: dict | None = None
+44 -3
View File
@@ -23,7 +23,7 @@ from .exceptions import (
RepoManagerError, RepoManagerError,
) )
from .models import FileDownloadResult, RepoFileInfo, RepoType from .models import FileDownloadResult, RepoFileInfo, RepoType
from .utils import prepare_aliyun_url, sparse_checkout_clone from .utils import get_aliyun_group_for_repo, prepare_aliyun_url, sparse_checkout_clone
class RepoFileManager: class RepoFileManager:
@@ -469,6 +469,35 @@ class RepoFileManager:
if all(f.path != file.path for f in file_list if f != file) if all(f.path != file.path for f in file_list if f != file)
] ]
def _clean_requirements_content(self, content: str) -> str:
"""
清理 requirements.txt 内容,移除包含非ASCII字符的注释行
这是为了防止 Windows 上 pip 使用 GBK 编码读取 UTF-8 文件时出错
参数:
content: requirements.txt 文件内容
返回:
str: 清理后的内容
"""
lines = content.splitlines()
cleaned_lines = []
for line in lines:
stripped = line.strip()
# 跳过空行
if not stripped:
continue
# 如果是注释行且包含非ASCII字符,跳过
if stripped.startswith("#"):
try:
stripped.encode("ascii")
except UnicodeEncodeError:
# 包含非ASCII字符的注释行,跳过
continue
cleaned_lines.append(line)
return "\n".join(cleaned_lines) + "\n" if cleaned_lines else ""
async def download_files( async def download_files(
self, self,
repo_url: str, repo_url: str,
@@ -576,6 +605,10 @@ class RepoFileManager:
local_path = file_path_mapping[repo_file_path] local_path = file_path_mapping[repo_file_path]
local_path.parent.mkdir(parents=True, exist_ok=True) local_path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, str): if isinstance(content, str):
# 对 requirements 文件特殊处理:移除包含非ASCII字符的注释行
# 防止 Windows GBK 编码问题
if repo_file_path.endswith(("requirements.txt", "requirement.txt")):
content = self._clean_requirements_content(content)
content_bytes = content.encode("utf-8") content_bytes = content.encode("utf-8")
else: else:
content_bytes = content content_bytes = content
@@ -604,8 +637,16 @@ class RepoFileManager:
result: FileDownloadResult, result: FileDownloadResult,
) -> FileDownloadResult: ) -> FileDownloadResult:
try: try:
# 获取仓库所属的分组名(外部插件仓库可能在不同分组下)
repo_name = (
repo_url.split("/tree/")[0].split("/")[-1].replace(".git", "").strip()
)
group_name = await get_aliyun_group_for_repo(repo_name)
aliyun_repo_url = prepare_aliyun_url(repo_url, group_name)
await sparse_checkout_clone( await sparse_checkout_clone(
repo_url=prepare_aliyun_url(repo_url), repo_url=aliyun_repo_url,
branch=branch, branch=branch,
sparse_path=sparse_path, sparse_path=sparse_path,
target_dir=target_dir, target_dir=target_dir,
@@ -618,7 +659,7 @@ class RepoFileManager:
total_size += f.stat().st_size total_size += f.stat().st_size
result.success = True result.success = True
result.file_size = total_size result.file_size = total_size
logger.info(f"sparse-checkout 克隆成功: {target_dir}") logger.info(f"sparse-checkout 克隆成功: {target_dir}:{aliyun_repo_url}")
return result return result
except GitUnavailableError as e: except GitUnavailableError as e:
logger.error(f"Git不可用: {e}") logger.error(f"Git不可用: {e}")
+38 -3
View File
@@ -227,11 +227,12 @@ async def sparse_checkout_clone(
shutil.move(str(source_path), str(target_path)) shutil.move(str(source_path), str(target_path))
def prepare_aliyun_url(repo_url: str) -> str: def prepare_aliyun_url(repo_url: str, group_name: str | None = None) -> str:
"""解析阿里云CodeUp的仓库URL """解析阿里云CodeUp的仓库URL
参数: 参数:
repo_url: 仓库URL repo_url: 仓库URL
group_name: 分组名称,如果为None则使用默认组织名称
返回: 返回:
str: 解析后的仓库URL str: 解析后的仓库URL
@@ -239,10 +240,12 @@ def prepare_aliyun_url(repo_url: str) -> str:
config = RepoConfig.get_instance() config = RepoConfig.get_instance()
repo_name = repo_url.split("/tree/")[0].split("/")[-1].replace(".git", "") repo_name = repo_url.split("/tree/")[0].split("/")[-1].replace(".git", "")
# 使用指定的分组名或默认组织名称
group = group_name or config.aliyun_codeup.organization_name
# 构建仓库URL # 构建仓库URL
# 阿里云CodeUp的仓库URL格式通常为: # 阿里云CodeUp的仓库URL格式通常为:
# https://codeup.aliyun.com/{organization_id}/{organization_name}/{repo_name}.git # https://codeup.aliyun.com/{organization_id}/{group_name}/{repo_name}.git
url = f"https://codeup.aliyun.com/{config.aliyun_codeup.organization_id}/{config.aliyun_codeup.organization_name}/{repo_name}.git" url = f"https://codeup.aliyun.com/{config.aliyun_codeup.organization_id}/{group}/{repo_name}.git"
# 添加访问令牌 - 使用base64解码后的令牌 # 添加访问令牌 - 使用base64解码后的令牌
if config.aliyun_codeup.rdc_access_token_encrypted: if config.aliyun_codeup.rdc_access_token_encrypted:
@@ -258,3 +261,35 @@ def prepare_aliyun_url(repo_url: str) -> str:
logger.error(f"解码RDC令牌失败: {e}") logger.error(f"解码RDC令牌失败: {e}")
return url return url
async def get_aliyun_group_for_repo(repo_name: str) -> str | None:
"""获取仓库所属的阿里云分组名
参数:
repo_name: 仓库名称
返回:
str | None: 分组名称,如果在核心映射中则返回None(使用默认组织名)
"""
from zhenxun.utils.github_utils.const import (
ALIYUN_EXTERNAL_PLUGIN_GROUPS,
ALIYUN_REPO_MAPPING,
)
from zhenxun.utils.github_utils.models import AliyunFileInfo
# 如果在核心映射中,使用默认组织名
if repo_name in ALIYUN_REPO_MAPPING:
return None
# 尝试从外部插件分组中查找
for group_path in ALIYUN_EXTERNAL_PLUGIN_GROUPS:
try:
repos = await AliyunFileInfo.list_group_repositories(group_path)
for repo in repos:
if repo.get("name") == repo_name:
return group_path
except Exception:
continue
return None