From 0c6aee2113a01af8665e38ec5e9eb55e789071da Mon Sep 17 00:00:00 2001 From: milomwang Date: Fri, 24 Apr 2026 15:08:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(ci):=20=E6=96=B0=E5=A2=9E=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=87=E8=B1=A1(CI)=E6=8F=92=E4=BB=B6=20-=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=A4=84=E7=90=86/=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8/=E5=AA=92=E4=BD=93=E5=A4=84=E7=90=86/AI?= =?UTF-8?q?=E8=AF=86=E5=88=AB/=E6=96=87=E6=A1=A3=E5=A4=84=E7=90=86/?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tccli/plugins/ci/__init__.py | 1399 +++++++++++++++++ tccli/plugins/ci/ai_recognition.py | 548 +++++++ tccli/plugins/ci/auditing.py | 467 ++++++ tccli/plugins/ci/ci_helpers.py | 231 +++ tccli/plugins/ci/doc_process.py | 221 +++ tccli/plugins/ci/file_process.py | 154 ++ tccli/plugins/ci/image_process.py | 444 ++++++ tccli/plugins/ci/media_process.py | 316 ++++ tccli/plugins/ci/tests/__init__.py | 1 + tccli/plugins/ci/tests/conftest.py | 13 + tccli/plugins/ci/tests/test_ai_recognition.py | 909 +++++++++++ tccli/plugins/ci/tests/test_auditing.py | 407 +++++ tccli/plugins/ci/tests/test_ci_helpers.py | 381 +++++ tccli/plugins/ci/tests/test_doc_process.py | 184 +++ tccli/plugins/ci/tests/test_file_process.py | 206 +++ tccli/plugins/ci/tests/test_image_process.py | 482 ++++++ tccli/plugins/ci/tests/test_media_process.py | 313 ++++ 17 files changed, 6676 insertions(+) create mode 100644 tccli/plugins/ci/__init__.py create mode 100644 tccli/plugins/ci/ai_recognition.py create mode 100644 tccli/plugins/ci/auditing.py create mode 100644 tccli/plugins/ci/ci_helpers.py create mode 100644 tccli/plugins/ci/doc_process.py create mode 100644 tccli/plugins/ci/file_process.py create mode 100644 tccli/plugins/ci/image_process.py create mode 100644 tccli/plugins/ci/media_process.py create mode 100644 tccli/plugins/ci/tests/__init__.py create mode 100644 tccli/plugins/ci/tests/conftest.py create mode 100644 tccli/plugins/ci/tests/test_ai_recognition.py create mode 100644 tccli/plugins/ci/tests/test_auditing.py create mode 100644 tccli/plugins/ci/tests/test_ci_helpers.py create mode 100644 tccli/plugins/ci/tests/test_doc_process.py create mode 100644 tccli/plugins/ci/tests/test_file_process.py create mode 100644 tccli/plugins/ci/tests/test_image_process.py create mode 100644 tccli/plugins/ci/tests/test_media_process.py diff --git a/tccli/plugins/ci/__init__.py b/tccli/plugins/ci/__init__.py new file mode 100644 index 0000000000..b69a3ebacf --- /dev/null +++ b/tccli/plugins/ci/__init__.py @@ -0,0 +1,1399 @@ +# encoding: utf-8 +""" +CI (Cloud Infinite / 数据万象) 命令行工具插件 + +将数据万象的全部功能集成到 TCCLI 中,包括六大类: + +一、图片处理: + - image_process: 图片下载时处理 + - image_process_saveas: 图片云上处理另存 + - image_upload_process: 图片上传时处理 + - image_info: 获取图片基本信息 + - image_exif_info: 获取图片 EXIF 信息 + - image_ave_color: 获取图片主色调 + - image_inspect: 异常图片检测 + - image_style_add: 添加图片样式 + - image_style_get: 查询图片样式 + - image_style_delete: 删除图片样式 + +二、内容审核: + - auditing_image: 图片批量审核(同步) + - auditing_video_submit: 提交视频审核 + - auditing_video_query: 查询视频审核结果 + - auditing_audio_submit: 提交音频审核 + - auditing_audio_query: 查询音频审核结果 + - auditing_text_submit: 提交文本审核 + - auditing_text_query: 查询文本审核结果 + - auditing_document_submit: 提交文档审核 + - auditing_document_query: 查询文档审核结果 + - auditing_webpage_submit: 提交网页审核 + - auditing_webpage_query: 查询网页审核结果 + - auditing_live_submit: 提交直播审核 + - auditing_live_cancel: 取消直播审核 + - auditing_virus_submit: 提交病毒检测 + - auditing_virus_query: 查询病毒检测结果 + - auditing_report_badcase: 举报不良内容 + +三、媒体处理: + - video_snapshot: 视频同步截帧 + - media_info: 获取媒体文件信息 + - media_job_submit: 创建媒体处理异步任务 + - media_job_query: 查询媒体处理任务 + - media_job_list: 列出媒体处理任务 + - media_job_cancel: 取消媒体处理任务 + +四、内容识别: + - ai_image_coloring: AI 图片上色 + - ai_enhance_image: AI 图片增强 + - ai_image_crop: AI 智能裁剪 + - ai_image_repair: AI 图片修复 + - ai_detect_face: AI 人脸检测 + - ai_face_effect: AI 人脸特效 + - ai_body_recognition: AI 人体识别 + - ai_id_card_ocr: 身份证 OCR 识别 + - ai_license_rec: 行驶证/驾驶证识别 + - ai_game_rec: 游戏场景识别 + - ocr_process: 通用 OCR 文字识别 + - detect_label: 图片标签识别 + - detect_car: 车辆识别 + - assess_quality: 图片质量评估 + - qrcode_detect: 二维码识别 + - qrcode_generate: 二维码生成 + - super_resolution: 图片超分辨率 + - goods_matting: 商品抠图 + - pic_matting: 通用抠图 + - portrait_matting: 人像抠图 + +五、文档处理: + - doc_preview: 同步文档预览(转图片) + - doc_preview_html: HTML 文档在线预览 + - doc_job_submit: 提交异步文档转码任务 + - doc_job_query: 查询异步文档转码任务 + - doc_job_list: 列出异步文档转码任务 + +六、文件处理: + - file_hash: 同步计算文件哈希 + - file_hash_job_submit: 提交异步哈希计算任务 + - file_uncompress_submit:提交文件解压缩任务 + - file_compress_submit: 提交文件压缩任务 + - file_zip_preview: 预览压缩包内容 + - file_process_jobs_query: 查询文件处理任务 +""" + +# ---- 图片处理(含基础处理 + EXIF/主色调/异常图片检测/样式管理)---- +from .image_process import ( + image_process, image_info, + image_process_saveas, image_upload_process, + image_exif_info, image_ave_color, image_inspect, + image_style_add, image_style_get, image_style_delete, +) + +# ---- 媒体处理(含同步截帧/媒体信息 + 异步任务)---- +from .media_process import ( + video_snapshot, media_info, + media_job_submit, media_job_query, + media_job_list, media_job_cancel, +) + +# ---- 内容审核 ---- +from .auditing import ( + auditing_image, + auditing_video_submit, auditing_video_query, + auditing_audio_submit, auditing_audio_query, + auditing_text_submit, auditing_text_query, + auditing_document_submit, auditing_document_query, + auditing_webpage_submit, auditing_webpage_query, + auditing_live_submit, auditing_live_cancel, + auditing_virus_submit, auditing_virus_query, + auditing_report_badcase, +) + +# ---- 内容识别(AI)---- +from .ai_recognition import ( + ai_image_coloring, ai_enhance_image, ai_image_crop, ai_image_repair, + ai_detect_face, ai_face_effect, ai_body_recognition, + ai_id_card_ocr, ai_license_rec, ai_game_rec, + ocr_process, detect_label, detect_car, assess_quality, + qrcode_detect, qrcode_generate, super_resolution, + goods_matting, pic_matting, portrait_matting, +) + +# ---- 文档处理 ---- +from .doc_process import ( + doc_preview, doc_preview_html, + doc_job_submit, doc_job_query, doc_job_list, +) + +# ---- 文件处理 ---- +from .file_process import ( + file_hash, file_hash_job_submit, + file_uncompress_submit, file_compress_submit, + file_zip_preview, file_process_jobs_query, +) + +service_name = "ci" +service_version = "2021-02-24" + +# ===================================================================== +# 辅助函数:构建 Request/Response 参数成员 +# ===================================================================== + +def _member(name, required=False, doc=""): + """构建参数成员定义的快捷函数""" + return { + "name": name, + "member": "string", + "type": "string", + "required": required, + "document": doc, + } + + +def _bucket(doc="存储桶名称,格式如 my-bucket-1250000000"): + return _member("bucket", required=True, doc=doc) + + +def _key(doc="COS 对象键"): + return _member("key", required=True, doc=doc) + + +def _optional_key(doc="COS 对象键"): + return _member("key", required=False, doc=doc) + + +def _output(doc="本地保存路径"): + return _member("local_path", required=False, doc=doc) + + +def _job_id(doc="任务 ID"): + return _member("job_id", required=True, doc=doc) + + +# ===================================================================== +# _spec 定义 +# ===================================================================== + +_spec = { + "metadata": { + "serviceShortName": service_name, + "apiVersion": service_version, + "description": "CI (Cloud Infinite / 数据万象) 全功能命令行工具", + }, + "actions": { + # ============================================================== + # 一、图片处理 + # ============================================================== + "image_process": { + "name": "图片下载时处理", + "document": "对COS上的图片进行实时处理并下载到本地(原图不变)。" + "支持所有数据万象处理规则:缩放、裁剪、旋转、格式转换、水印等。" + "多步处理可使用管道操作符 | 连接。" + "rule参考:https://cloud.tencent.com/document/product/460/6924", + "input": "image_processRequest", + "output": "image_processResponse", + "action_caller": image_process, + }, + "image_process_saveas": { + "name": "图片云上处理另存", + "document": "对COS上已有的图片进行处理,结果另存为新的对象(原图不变)。" + "rule参考:https://cloud.tencent.com/document/product/460/6924", + "input": "image_process_saveasRequest", + "output": "image_process_saveasResponse", + "action_caller": image_process_saveas, + }, + "image_upload_process": { + "name": "图片上传时处理", + "document": "将本地图片上传到COS,上传的同时进行图片处理并保存结果。" + "rule参考:https://cloud.tencent.com/document/product/460/6924", + "input": "image_upload_processRequest", + "output": "image_upload_processResponse", + "action_caller": image_upload_process, + }, + "image_info": { + "name": "获取图片信息", + "document": "获取COS上图片的基本信息,包括格式、尺寸、大小等", + "input": "image_infoRequest", + "output": "image_infoResponse", + "action_caller": image_info, + }, + + # ---- 图片处理补充 ---- + "image_exif_info": { + "name": "获取图片 EXIF 信息", + "document": "获取COS上图片的EXIF元数据(拍摄设备、时间、GPS等)", + "input": "image_exif_infoRequest", + "output": "image_exif_infoResponse", + "action_caller": image_exif_info, + }, + "image_ave_color": { + "name": "获取图片主色调", + "document": "获取COS上图片的平均主色调(十六进制颜色值)", + "input": "image_ave_colorRequest", + "output": "image_ave_colorResponse", + "action_caller": image_ave_color, + }, + "image_inspect": { + "name": "异常图片检测", + "document": "检测COS上图片中是否隐含其他类型的可疑文件(如在图片格式中嵌入视频或其他文件)。" + "API参考:https://cloud.tencent.com/document/product/460/75997", + "input": "image_inspectRequest", + "output": "image_inspectResponse", + "action_caller": image_inspect, + }, + "image_style_add": { + "name": "添加图片样式", + "document": "为存储桶添加图片处理样式,以便后续通过样式名称快速应用处理规则", + "input": "image_style_addRequest", + "output": "image_style_addResponse", + "action_caller": image_style_add, + }, + "image_style_get": { + "name": "查询图片样式", + "document": "查询存储桶已设置的图片处理样式列表", + "input": "image_style_getRequest", + "output": "image_style_getResponse", + "action_caller": image_style_get, + }, + "image_style_delete": { + "name": "删除图片样式", + "document": "删除存储桶已有的图片处理样式", + "input": "image_style_deleteRequest", + "output": "image_style_deleteResponse", + "action_caller": image_style_delete, + }, + + # ============================================================== + # 二、内容审核 + # ============================================================== + "auditing_image": { + "name": "图片批量审核", + "document": "对一张或多张图片进行内容审核(同步),支持检测涉黄、涉政、广告等违规内容。" + "API参考:https://cloud.tencent.com/document/product/460/37318", + "input": "auditing_imageRequest", + "output": "auditing_imageResponse", + "action_caller": auditing_image, + }, + "auditing_video_submit": { + "name": "提交视频审核", + "document": "提交视频内容审核异步任务,审核结果通过查询接口获取。" + "支持截帧审核和音频审核。", + "input": "auditing_video_submitRequest", + "output": "auditing_video_submitResponse", + "action_caller": auditing_video_submit, + }, + "auditing_video_query": { + "name": "查询视频审核结果", + "document": "查询视频审核异步任务的结果", + "input": "auditing_video_queryRequest", + "output": "auditing_video_queryResponse", + "action_caller": auditing_video_query, + }, + "auditing_audio_submit": { + "name": "提交音频审核", + "document": "提交音频内容审核异步任务", + "input": "auditing_audio_submitRequest", + "output": "auditing_audio_submitResponse", + "action_caller": auditing_audio_submit, + }, + "auditing_audio_query": { + "name": "查询音频审核结果", + "document": "查询音频审核异步任务的结果", + "input": "auditing_audio_queryRequest", + "output": "auditing_audio_queryResponse", + "action_caller": auditing_audio_query, + }, + "auditing_text_submit": { + "name": "提交文本审核", + "document": "提交文本内容审核异步任务,支持COS对象、URL和直接文本内容", + "input": "auditing_text_submitRequest", + "output": "auditing_text_submitResponse", + "action_caller": auditing_text_submit, + }, + "auditing_text_query": { + "name": "查询文本审核结果", + "document": "查询文本审核异步任务的结果", + "input": "auditing_text_queryRequest", + "output": "auditing_text_queryResponse", + "action_caller": auditing_text_query, + }, + "auditing_document_submit": { + "name": "提交文档审核", + "document": "提交文档内容审核异步任务,支持PDF/Word/Excel/PPT等格式", + "input": "auditing_document_submitRequest", + "output": "auditing_document_submitResponse", + "action_caller": auditing_document_submit, + }, + "auditing_document_query": { + "name": "查询文档审核结果", + "document": "查询文档审核异步任务的结果", + "input": "auditing_document_queryRequest", + "output": "auditing_document_queryResponse", + "action_caller": auditing_document_query, + }, + "auditing_webpage_submit": { + "name": "提交网页审核", + "document": "提交网页内容审核异步任务", + "input": "auditing_webpage_submitRequest", + "output": "auditing_webpage_submitResponse", + "action_caller": auditing_webpage_submit, + }, + "auditing_webpage_query": { + "name": "查询网页审核结果", + "document": "查询网页审核异步任务的结果", + "input": "auditing_webpage_queryRequest", + "output": "auditing_webpage_queryResponse", + "action_caller": auditing_webpage_query, + }, + "auditing_live_submit": { + "name": "提交直播审核", + "document": "提交直播流内容审核任务,支持RTMP/HLS/FLV直播流", + "input": "auditing_live_submitRequest", + "output": "auditing_live_submitResponse", + "action_caller": auditing_live_submit, + }, + "auditing_live_cancel": { + "name": "取消直播审核", + "document": "取消正在进行的直播流审核任务", + "input": "auditing_live_cancelRequest", + "output": "auditing_live_cancelResponse", + "action_caller": auditing_live_cancel, + }, + "auditing_virus_submit": { + "name": "提交病毒检测", + "document": "提交文件病毒检测任务", + "input": "auditing_virus_submitRequest", + "output": "auditing_virus_submitResponse", + "action_caller": auditing_virus_submit, + }, + "auditing_virus_query": { + "name": "查询病毒检测结果", + "document": "查询病毒检测任务的结果", + "input": "auditing_virus_queryRequest", + "output": "auditing_virus_queryResponse", + "action_caller": auditing_virus_query, + }, + "auditing_report_badcase": { + "name": "审核结果反馈", + "document": "审核结果反馈,用于反馈审核漏检", + "input": "auditing_report_badcaseRequest", + "output": "auditing_report_badcaseResponse", + "action_caller": auditing_report_badcase, + }, + + # ============================================================== + # 三、媒体处理 + # ============================================================== + "video_snapshot": { + "name": "视频同步截帧", + "document": "对COS上的视频文件进行同步截帧,获取指定时间点的画面截图并下载到本地。" + "API参考:https://cloud.tencent.com/document/product/460/49283", + "input": "video_snapshotRequest", + "output": "video_snapshotResponse", + "action_caller": video_snapshot, + }, + "media_info": { + "name": "获取媒体信息", + "document": "获取COS上媒体文件(视频/音频)的详细信息。" + "API参考:https://cloud.tencent.com/document/product/460/49284", + "input": "media_infoRequest", + "output": "media_infoResponse", + "action_caller": media_info, + }, + "media_job_submit": { + "name": "创建媒体处理任务", + "document": "创建异步媒体处理任务,支持转码、截帧、动图、拼接、智能封面等。" + "通过 tag 参数指定任务类型:Transcode/Snapshot/Animation/Concat/" + "SmartCover/VideoProcess/VideoMontage/VoiceSeparate/SDRtoHDR/" + "DigitalWatermark/SuperResolution/VideoTag 等。" + "操作参数通过 operation(JSON字符串)传入。", + "input": "media_job_submitRequest", + "output": "media_job_submitResponse", + "action_caller": media_job_submit, + }, + "media_job_query": { + "name": "查询媒体处理任务", + "document": "查询指定的媒体处理异步任务的状态和结果", + "input": "media_job_queryRequest", + "output": "media_job_queryResponse", + "action_caller": media_job_query, + }, + "media_job_list": { + "name": "列出媒体处理任务", + "document": "列出指定类型的媒体处理任务列表", + "input": "media_job_listRequest", + "output": "media_job_listResponse", + "action_caller": media_job_list, + }, + "media_job_cancel": { + "name": "取消媒体处理任务", + "document": "取消指定的媒体处理异步任务", + "input": "media_job_cancelRequest", + "output": "media_job_cancelResponse", + "action_caller": media_job_cancel, + }, + + # ============================================================== + # 四、内容识别(AI) + # ============================================================== + "ai_image_coloring": { + "name": "AI 图片上色", + "document": "将黑白图片进行智能上色,支持 COS 对象或外部 URL", + "input": "ai_image_coloringRequest", + "output": "ai_image_coloringResponse", + "action_caller": ai_image_coloring, + }, + "ai_enhance_image": { + "name": "AI 图片增强", + "document": "对图片进行清晰度增强和去噪处理", + "input": "ai_enhance_imageRequest", + "output": "ai_enhance_imageResponse", + "action_caller": ai_enhance_image, + }, + "ai_image_crop": { + "name": "AI 智能裁剪", + "document": "基于 AI 识别图片主体,自动进行智能裁剪", + "input": "ai_image_cropRequest", + "output": "ai_image_cropResponse", + "action_caller": ai_image_crop, + }, + "ai_image_repair": { + "name": "AI 图片修复", + "document": "对图片进行智能修复,去除遮挡物", + "input": "ai_image_repairRequest", + "output": "ai_image_repairResponse", + "action_caller": ai_image_repair, + }, + "ai_detect_face": { + "name": "AI 人脸检测", + "document": "检测图片中的人脸位置和属性信息", + "input": "ai_detect_faceRequest", + "output": "ai_detect_faceResponse", + "action_caller": ai_detect_face, + }, + "ai_face_effect": { + "name": "AI 人脸特效", + "document": "对图片中的人脸进行特效处理(年龄变换、性别转换等)", + "input": "ai_face_effectRequest", + "output": "ai_face_effectResponse", + "action_caller": ai_face_effect, + }, + "ai_body_recognition": { + "name": "AI 人体识别", + "document": "检测图片中的人体位置", + "input": "ai_body_recognitionRequest", + "output": "ai_body_recognitionResponse", + "action_caller": ai_body_recognition, + }, + "ai_id_card_ocr": { + "name": "身份证 OCR 识别", + "document": "识别身份证正面/背面信息", + "input": "ai_id_card_ocrRequest", + "output": "ai_id_card_ocrResponse", + "action_caller": ai_id_card_ocr, + }, + "ai_license_rec": { + "name": "行驶证/驾驶证识别", + "document": "识别行驶证或驾驶证信息", + "input": "ai_license_recRequest", + "output": "ai_license_recResponse", + "action_caller": ai_license_rec, + }, + "ai_game_rec": { + "name": "游戏场景识别", + "document": "识别游戏场景截图的内容", + "input": "ai_game_recRequest", + "output": "ai_game_recResponse", + "action_caller": ai_game_rec, + }, + "ocr_process": { + "name": "通用 OCR 文字识别", + "document": "识别图片或PDF中的文字内容", + "input": "ocr_processRequest", + "output": "ocr_processResponse", + "action_caller": ocr_process, + }, + "detect_label": { + "name": "图片标签识别", + "document": "识别图片中的内容标签(场景、物品等)", + "input": "detect_labelRequest", + "output": "detect_labelResponse", + "action_caller": detect_label, + }, + "detect_car": { + "name": "车辆识别", + "document": "识别图片中的车辆品牌、型号、颜色等信息", + "input": "detect_carRequest", + "output": "detect_carResponse", + "action_caller": detect_car, + }, + "assess_quality": { + "name": "图片质量评估", + "document": "评估图片的美学质量分数", + "input": "assess_qualityRequest", + "output": "assess_qualityResponse", + "action_caller": assess_quality, + }, + "qrcode_detect": { + "name": "二维码识别", + "document": "识别图片中的二维码/条形码内容", + "input": "qrcode_detectRequest", + "output": "qrcode_detectResponse", + "action_caller": qrcode_detect, + }, + "qrcode_generate": { + "name": "二维码生成", + "document": "根据文本内容生成二维码图片", + "input": "qrcode_generateRequest", + "output": "qrcode_generateResponse", + "action_caller": qrcode_generate, + }, + "super_resolution": { + "name": "图片超分辨率", + "document": "将图片放大并提升清晰度", + "input": "super_resolutionRequest", + "output": "super_resolutionResponse", + "action_caller": super_resolution, + }, + "goods_matting": { + "name": "商品抠图", + "document": "自动识别并抠出图片中的商品主体", + "input": "goods_mattingRequest", + "output": "goods_mattingResponse", + "action_caller": goods_matting, + }, + "pic_matting": { + "name": "通用抠图", + "document": "自动识别并抠出图片前景主体", + "input": "pic_mattingRequest", + "output": "pic_mattingResponse", + "action_caller": pic_matting, + }, + "portrait_matting": { + "name": "人像抠图", + "document": "自动识别并抠出图片中的人像", + "input": "portrait_mattingRequest", + "output": "portrait_mattingResponse", + "action_caller": portrait_matting, + }, + + # ============================================================== + # 五、文档处理 + # ============================================================== + "doc_preview": { + "name": "同步文档预览", + "document": "将文档(PDF/Word/Excel/PPT等)同步转为图片并下载到本地", + "input": "doc_previewRequest", + "output": "doc_previewResponse", + "action_caller": doc_preview, + }, + "doc_preview_html": { + "name": "HTML 文档在线预览", + "document": "获取文档的 HTML 预览内容", + "input": "doc_preview_htmlRequest", + "output": "doc_preview_htmlResponse", + "action_caller": doc_preview_html, + }, + "doc_job_submit": { + "name": "提交文档转码任务", + "document": "提交异步文档转码任务,将文档转为图片或PDF", + "input": "doc_job_submitRequest", + "output": "doc_job_submitResponse", + "action_caller": doc_job_submit, + }, + "doc_job_query": { + "name": "查询文档转码任务", + "document": "查询异步文档转码任务的状态和结果", + "input": "doc_job_queryRequest", + "output": "doc_job_queryResponse", + "action_caller": doc_job_query, + }, + "doc_job_list": { + "name": "列出文档转码任务", + "document": "列出异步文档转码任务列表", + "input": "doc_job_listRequest", + "output": "doc_job_listResponse", + "action_caller": doc_job_list, + }, + + # ============================================================== + # 六、文件处理 + # ============================================================== + "file_hash": { + "name": "同步计算文件哈希", + "document": "同步计算COS上文件的哈希值(MD5/SHA1/SHA256)", + "input": "file_hashRequest", + "output": "file_hashResponse", + "action_caller": file_hash, + }, + "file_hash_job_submit": { + "name": "提交异步哈希计算任务", + "document": "提交异步文件哈希计算任务", + "input": "file_hash_job_submitRequest", + "output": "file_hash_job_submitResponse", + "action_caller": file_hash_job_submit, + }, + "file_uncompress_submit": { + "name": "提交文件解压缩任务", + "document": "提交COS上压缩文件的解压缩任务", + "input": "file_uncompress_submitRequest", + "output": "file_uncompress_submitResponse", + "action_caller": file_uncompress_submit, + }, + "file_compress_submit": { + "name": "提交文件压缩任务", + "document": "将COS上的多个文件压缩为一个压缩包", + "input": "file_compress_submitRequest", + "output": "file_compress_submitResponse", + "action_caller": file_compress_submit, + }, + "file_zip_preview": { + "name": "预览压缩包内容", + "document": "列出COS上压缩包内的文件列表", + "input": "file_zip_previewRequest", + "output": "file_zip_previewResponse", + "action_caller": file_zip_preview, + }, + "file_process_jobs_query": { + "name": "查询文件处理任务", + "document": "查询文件处理(哈希/压缩/解压)任务的状态和结果", + "input": "file_process_jobs_queryRequest", + "output": "file_process_jobs_queryResponse", + "action_caller": file_process_jobs_query, + }, + }, + + "objects": { + + # ============================================================== + # 图片处理 + # ============================================================== + "image_processRequest": { + "members": [ + _bucket(), + _key(doc="图片在COS上的对象键(Key)"), + _member("rule", required=True, doc="数据万象处理规则,如 imageView2/2/w/800/h/600"), + _output(doc="处理后的图片保存到本地的路径,默认保存到当前目录"), + ], + }, + "image_processResponse": { + "members": [ + _member("Status", doc="操作状态,成功返回 Success"), + _member("Output", doc="处理后图片保存的本地路径"), + _member("ContentType", doc="处理后图片的 Content-Type"), + _member("ContentLength", doc="处理后图片的大小(字节)"), + ], + }, + "image_process_saveasRequest": { + "members": [ + _bucket(), + _key(doc="原图在COS上的对象键"), + _member("rule", required=True, doc="数据万象处理规则"), + _member("savekey", required=True, doc="处理后图片在COS上的存储路径"), + ], + }, + "image_process_saveasResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("RequestId", doc="请求ID"), + _member("OriginalImage", doc="原图信息(Format、Width、Height)"), + _member("ProcessedImages", doc="处理后的图片信息列表"), + ], + }, + "image_upload_processRequest": { + "members": [ + _bucket(), + _member("local", required=True, doc="本地图片文件路径"), + _key(doc="上传到COS的对象键(原图存储路径)"), + _member("rule", required=True, doc="数据万象处理规则"), + _member("savekey", required=False, doc="处理后图片的存储路径,默认与 key 相同(覆盖原图)"), + ], + }, + "image_upload_processResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("RequestId", doc="请求ID"), + _member("OriginalImage", doc="原图信息"), + _member("ProcessedImages", doc="处理后的图片信息列表"), + ], + }, + "image_infoRequest": { + "members": [_bucket(), _key(doc="图片在COS上的对象键")], + }, + "image_infoResponse": { + "members": [ + _member("format", doc="图片格式"), + _member("width", doc="图片宽度(像素)"), + _member("height", doc="图片高度(像素)"), + _member("size", doc="图片大小(字节)"), + ], + }, + "image_exif_infoRequest": { + "members": [_bucket(), _key(doc="图片在COS上的对象键")], + }, + "image_exif_infoResponse": {"members": []}, + + "image_ave_colorRequest": { + "members": [_bucket(), _key(doc="图片在COS上的对象键")], + }, + "image_ave_colorResponse": {"members": []}, + + "image_inspectRequest": { + "members": [_bucket(), _key(doc="图片在COS上的对象键")], + }, + "image_inspectResponse": { + "members": [ + _member("picSize", doc="检测的原图大小(Bytes)"), + _member("picType", doc="检测的原图类型(如 jpg、png)"), + _member("suspicious", doc="是否检测到图片格式以外的文件(true/false)"), + _member("suspiciousBeginByte", doc="可疑文件起始字节位置(Bytes)"), + _member("suspiciousEndByte", doc="可疑文件末尾字节位置(Bytes)"), + _member("suspiciousSize", doc="可疑文件大小"), + _member("suspiciousType", doc="可疑文件类型(如 MPEG-TS)"), + ], + }, + + "image_style_addRequest": { + "members": [ + _bucket(), + _member("style_name", required=True, doc="样式名称"), + _member("style_body", required=True, doc="样式内容(处理规则)"), + ], + }, + "image_style_addResponse": {"members": []}, + + "image_style_getRequest": { + "members": [ + _bucket(), + _member("style_name", doc="样式名称,不指定则列出所有"), + ], + }, + "image_style_getResponse": {"members": []}, + + "image_style_deleteRequest": { + "members": [ + _bucket(), + _member("style_name", required=True, doc="样式名称"), + ], + }, + "image_style_deleteResponse": {"members": []}, + + + # ============================================================== + # 媒体处理 + # ============================================================== + "video_snapshotRequest": { + "members": [ + _bucket(), + _key(doc="视频在COS上的对象键"), + _member("snapshot_time", required=True, doc="截帧时间点,单位为秒(支持小数,如 1.5)"), + _member("width", doc="截图宽度(像素),范围[0,4096],默认0表示原始宽度"), + _member("height", doc="截图高度(像素),范围[0,4096],默认0表示原始高度"), + _member("format", doc="截图格式,支持 jpg/png,默认 jpg"), + _member("mode", doc="截帧方式:exactframe(精确帧,默认) 或 keyframe(最近关键帧)"), + _member("rotate", doc="旋转方式:auto(自动旋转,默认) 或 off(不旋转)"), + _output(doc="截图保存到本地的路径,默认保存到当前目录"), + ], + }, + "video_snapshotResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="截图保存的本地路径"), + _member("ContentType", doc="截图的 Content-Type"), + _member("ContentLength", doc="截图的大小(字节)"), + _member("SnapshotTime", doc="截帧时间点"), + ], + }, + "media_infoRequest": { + "members": [_bucket(), _key(doc="媒体文件在COS上的对象键")], + }, + "media_infoResponse": { + "members": [ + _member("Format", doc="容器格式信息"), + _member("Video", doc="视频流信息"), + _member("Audio", doc="音频流信息"), + ], + }, + "media_job_submitRequest": { + "members": [ + _bucket(), + _key(doc="输入文件的COS对象键"), + _member("tag", required=True, + doc="任务类型标签:Transcode/Snapshot/Animation/Concat/" + "SmartCover/VideoProcess/VideoMontage/VoiceSeparate/" + "SDRtoHDR/DigitalWatermark/SuperResolution/VideoTag等"), + _member("operation", doc="操作参数JSON字符串"), + _member("output_bucket", doc="输出存储桶"), + _member("output_key", doc="输出文件的COS对象键"), + _member("output_region", doc="输出区域"), + _member("queue_id", doc="队列ID"), + _member("callback", doc="回调URL"), + ], + }, + "media_job_submitResponse": {"members": []}, + + "media_job_queryRequest": { + "members": [_bucket(), _job_id(doc="媒体处理任务ID")], + }, + "media_job_queryResponse": {"members": []}, + + "media_job_listRequest": { + "members": [ + _bucket(), + _member("tag", required=True, doc="任务类型标签"), + _member("queue_id", doc="队列ID"), + _member("status", doc="任务状态 All/Submitted/Running/Success/Failed/Pause/Cancel"), + _member("size", doc="每页数量,默认10"), + _member("next_token", doc="翻页标记"), + _member("order_by_time", doc="排序方式 Desc/Asc"), + ], + }, + "media_job_listResponse": {"members": []}, + + "media_job_cancelRequest": { + "members": [_bucket(), _job_id(doc="媒体处理任务ID")], + }, + "media_job_cancelResponse": {"members": []}, + + + # ============================================================== + # 内容审核 + # ============================================================== + "auditing_imageRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键,多个用逗号分隔"), + _member("url", doc="外部图片URL,多个用逗号分隔"), + _member("detect_type", doc="审核类型,如 porn,ads,politics,terrorism"), + _member("biz_type", doc="审核策略ID"), + ], + }, + "auditing_imageResponse": {"members": []}, + + "auditing_video_submitRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部视频URL"), + _member("detect_type", doc="审核类型"), + _member("biz_type", doc="审核策略ID"), + _member("snapshot_mode", doc="截帧模式 Interval/Average/KeyFrame"), + _member("snapshot_count", doc="截帧数量"), + ], + }, + "auditing_video_submitResponse": {"members": []}, + + "auditing_video_queryRequest": { + "members": [_bucket(), _job_id(doc="审核任务ID")], + }, + "auditing_video_queryResponse": {"members": []}, + + "auditing_audio_submitRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部音频URL"), + _member("detect_type", doc="审核类型"), + _member("biz_type", doc="审核策略ID"), + ], + }, + "auditing_audio_submitResponse": {"members": []}, + + "auditing_audio_queryRequest": { + "members": [_bucket(), _job_id(doc="审核任务ID")], + }, + "auditing_audio_queryResponse": {"members": []}, + + "auditing_text_submitRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部文本URL"), + _member("content", doc="纯文本内容(Base64编码)"), + _member("detect_type", doc="审核类型"), + _member("biz_type", doc="审核策略ID"), + ], + }, + "auditing_text_submitResponse": {"members": []}, + + "auditing_text_queryRequest": { + "members": [_bucket(), _job_id(doc="审核任务ID")], + }, + "auditing_text_queryResponse": {"members": []}, + + "auditing_document_submitRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部文档URL"), + _member("doc_type", doc="文档类型 pdf/docx/pptx/xlsx等"), + _member("detect_type", doc="审核类型"), + _member("biz_type", doc="审核策略ID"), + ], + }, + "auditing_document_submitResponse": {"members": []}, + + "auditing_document_queryRequest": { + "members": [_bucket(), _job_id(doc="审核任务ID")], + }, + "auditing_document_queryResponse": {"members": []}, + + "auditing_webpage_submitRequest": { + "members": [ + _bucket(), + _member("url", required=True, doc="网页URL"), + _member("detect_type", doc="审核类型"), + _member("biz_type", doc="审核策略ID"), + ], + }, + "auditing_webpage_submitResponse": {"members": []}, + + "auditing_webpage_queryRequest": { + "members": [_bucket(), _job_id(doc="审核任务ID")], + }, + "auditing_webpage_queryResponse": {"members": []}, + + "auditing_live_submitRequest": { + "members": [ + _bucket(), + _member("url", required=True, doc="直播流URL(RTMP/HLS/FLV)"), + _member("detect_type", doc="审核类型"), + _member("biz_type", doc="审核策略ID"), + _member("callback", doc="回调地址"), + ], + }, + "auditing_live_submitResponse": {"members": []}, + + "auditing_live_cancelRequest": { + "members": [_bucket(), _job_id(doc="审核任务ID")], + }, + "auditing_live_cancelResponse": {"members": []}, + + "auditing_virus_submitRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部文件URL"), + _member("detect_type", doc="检测类型"), + ], + }, + "auditing_virus_submitResponse": {"members": []}, + + "auditing_virus_queryRequest": { + "members": [_bucket(), _job_id(doc="审核任务ID")], + }, + "auditing_virus_queryResponse": {"members": []}, + + "auditing_report_badcaseRequest": { + "members": [ + _bucket(), + _member("content_type", required=True, doc="内容类型 1:图片 2:视频 3:音频 4:文本"), + _member("url", required=True, doc="被举报内容的URL"), + _member("label", required=True, doc="恶意标签 Porn/Ads/Politics/Terrorism等"), + _member("suggestion_label", doc="期望处理建议 Block/Review/Normal"), + ], + }, + "auditing_report_badcaseResponse": {"members": []}, + + + # ============================================================== + # 内容识别(AI) + # ============================================================== + "ai_image_coloringRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _output(doc="结果图片本地保存路径"), + ], + }, + "ai_image_coloringResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="结果保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + ], + }, + "ai_enhance_imageRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _member("denoise", doc="去噪强度 0-5"), + _member("sharpen", doc="锐化强度 0-5"), + _output(), + ], + }, + "ai_enhance_imageResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="结果保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + ], + }, + "ai_image_cropRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _member("width", required=True, doc="目标宽度"), + _member("height", required=True, doc="目标高度"), + _member("fixed", doc="是否固定尺寸 0/1"), + _output(), + ], + }, + "ai_image_cropResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="结果保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + ], + }, + "ai_image_repairRequest": { + "members": [ + _bucket(), + _key(doc="COS对象键"), + _member("mask_key", doc="遮罩图COS对象键"), + _member("mask_url", doc="遮罩图URL"), + _output(), + ], + }, + "ai_image_repairResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="结果保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + ], + }, + "ai_detect_faceRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _member("max_face_num", doc="最大人脸数,默认1"), + ], + }, + "ai_detect_faceResponse": {"members": []}, + + "ai_face_effectRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _member("effect_type", required=True, doc="特效类型"), + _output(), + ], + }, + "ai_face_effectResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="结果保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + ], + }, + "ai_body_recognitionRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _output(doc="结果图片本地保存路径"), + ], + }, + "ai_body_recognitionResponse": {"members": []}, + + "ai_id_card_ocrRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _member("card_side", doc="正面FRONT/背面BACK"), + ], + }, + "ai_id_card_ocrResponse": {"members": []}, + + "ai_license_recRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _member("card_type", doc="DRIVING 驾驶证/VEHICLE 行驶证"), + ], + }, + "ai_license_recResponse": {"members": []}, + + "ai_game_recRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _output(doc="结果图片本地保存路径"), + ], + }, + "ai_game_recResponse": {"members": []}, + + "ocr_processRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _member("language_type", doc="语言类型 zh/en等"), + _member("ispdf", doc="是否是PDF true/false"), + _member("pdf_pagenumber", doc="PDF页码"), + ], + }, + "ocr_processResponse": {"members": []}, + + "detect_labelRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _output(doc="结果图片本地保存路径"), + ], + }, + "detect_labelResponse": {"members": []}, + "detect_carRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _output(doc="结果图片本地保存路径"), + ], + }, + "detect_carResponse": {"members": []}, + "assess_qualityRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _output(doc="结果图片本地保存路径"), + ], + }, + "assess_qualityResponse": {"members": []}, + + "qrcode_detectRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _member("cover", doc="是否覆盖二维码区域 0/1"), + ], + }, + "qrcode_detectResponse": {"members": []}, + + "qrcode_generateRequest": { + "members": [ + _bucket(), + _member("text", required=True, doc="二维码内容文本"), + _member("width", doc="二维码宽度,默认200"), + _member("mode", doc="生成类型 0:二维码 1:条形码"), + _output(doc="生成的二维码图片本地保存路径"), + ], + }, + "qrcode_generateResponse": {"members": []}, + + "super_resolutionRequest": { + "members": [ + _bucket(), + _key(doc="COS对象键"), + _output(), + ], + }, + "super_resolutionResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="结果保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + ], + }, + "goods_mattingRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _output(doc="结果图片本地保存路径"), + ], + }, + "goods_mattingResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="结果保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + ], + }, + "pic_mattingRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _output(doc="结果图片本地保存路径"), + ], + }, + "pic_mattingResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="结果保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + ], + }, + "portrait_mattingRequest": { + "members": [ + _bucket(), + _optional_key(doc="COS对象键"), + _member("url", doc="外部图片URL"), + _output(doc="结果图片本地保存路径"), + ], + }, + "portrait_mattingResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="结果保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + ], + }, + + # ============================================================== + # 文档处理 + # ============================================================== + "doc_previewRequest": { + "members": [ + _bucket(), + _key(doc="文档在COS上的对象键"), + _member("page", doc="预览页码,从1开始,默认1"), + _member("src_type", doc="源文件类型,如 pdf/docx/xlsx/pptx"), + _member("image_type", doc="输出图片类型 png/jpg,默认png"), + _member("dsttype", doc="输出格式 png/jpg/pdf,默认png"), + _output(doc="预览图片保存到本地的路径"), + ], + }, + "doc_previewResponse": { + "members": [ + _member("Status", doc="操作状态"), + _member("Output", doc="保存的本地路径"), + _member("ContentType", doc="Content-Type"), + _member("ContentLength", doc="大小(字节)"), + _member("Page", doc="当前页码"), + _member("TotalPage", doc="总页数"), + _member("TotalSheet", doc="总Sheet数(Excel)"), + ], + }, + "doc_preview_htmlRequest": { + "members": [ + _bucket(), + _key(doc="文档在COS上的对象键"), + _member("src_type", doc="源文件类型"), + _output(doc="保存HTML内容到本地的路径"), + ], + }, + "doc_preview_htmlResponse": {"members": []}, + + "doc_job_submitRequest": { + "members": [ + _bucket(), + _key(doc="文档在COS上的对象键"), + _member("output_bucket", doc="输出存储桶,默认同bucket"), + _member("output_key", required=True, doc="输出文件的COS对象键"), + _member("src_type", doc="源文件类型"), + _member("tgt_type", doc="目标文件类型 png/jpg/pdf,默认png"), + _member("start_page", doc="起始页码,默认1"), + _member("end_page", doc="结束页码,默认-1(所有页)"), + _member("sheet_id", doc="Sheet编号(Excel文件)"), + ], + }, + "doc_job_submitResponse": {"members": []}, + + "doc_job_queryRequest": { + "members": [_bucket(), _job_id(doc="文档转码任务ID")], + }, + "doc_job_queryResponse": {"members": []}, + + "doc_job_listRequest": { + "members": [ + _bucket(), + _member("size", doc="每页数量,默认10"), + _member("page", doc="页码,默认1"), + _member("status", doc="任务状态 All/Submitted/Running/Success/Failed/Pause/Cancel"), + ], + }, + "doc_job_listResponse": {"members": []}, + + + # ============================================================== + # 文件处理 + # ============================================================== + "file_hashRequest": { + "members": [ + _bucket(), + _key(doc="COS对象键"), + _member("hash_type", required=True, doc="哈希类型 md5/sha1/sha256"), + _member("add_to_header", doc="是否将哈希值写入自定义头部 true/false"), + ], + }, + "file_hashResponse": {"members": []}, + + "file_hash_job_submitRequest": { + "members": [ + _bucket(), + _key(doc="COS对象键"), + _member("hash_type", required=True, doc="哈希类型 md5/sha1/sha256"), + _member("add_to_header", doc="是否将哈希值写入自定义头部"), + _member("callback", doc="回调地址"), + ], + }, + "file_hash_job_submitResponse": {"members": []}, + + "file_uncompress_submitRequest": { + "members": [ + _bucket(), + _key(doc="COS上的压缩文件对象键"), + _member("output_bucket", doc="解压输出的存储桶,默认同bucket"), + _member("output_prefix", doc="解压输出的前缀路径"), + _member("password", doc="压缩包密码"), + ], + }, + "file_uncompress_submitResponse": {"members": []}, + + "file_compress_submitRequest": { + "members": [ + _bucket(), + _member("keys", required=True, doc="要压缩的COS对象键列表,逗号分隔"), + _member("output_key", required=True, doc="压缩后文件的COS对象键"), + _member("compress_format", doc="压缩格式 zip/tar/tar.gz,默认zip"), + _member("prefix", doc="压缩包内文件前缀"), + ], + }, + "file_compress_submitResponse": {"members": []}, + + "file_zip_previewRequest": { + "members": [_bucket(), _key(doc="压缩包在COS上的对象键")], + }, + "file_zip_previewResponse": {"members": []}, + + "file_process_jobs_queryRequest": { + "members": [_bucket(), _job_id(doc="文件处理任务ID")], + }, + "file_process_jobs_queryResponse": {"members": []}, + }, + "version": "1.0", +} + + +def register_service(specs): + """注册 CI 服务到 TCCLI 的服务列表 + + 由 TCCLI 的 plugin.py 中 import_plugins() 调用。 + + Args: + specs: TCCLI 全局服务规范字典 + """ + specs[service_name] = { + service_version: _spec, + } diff --git a/tccli/plugins/ci/ai_recognition.py b/tccli/plugins/ci/ai_recognition.py new file mode 100644 index 0000000000..2596a08dbc --- /dev/null +++ b/tccli/plugins/ci/ai_recognition.py @@ -0,0 +1,548 @@ +# -*- coding: utf-8 -*- +""" +内容识别(AI)模块 + +提供数据万象 AI 内容识别功能的 CLI 命令,包括: +- 图片上色(ai_image_coloring) +- 图片增强(ai_enhance_image) +- 智能裁剪(ai_image_crop) +- 图片修复(ai_image_repair) +- 人脸检测(ai_detect_face) +- 人脸特效(ai_face_effect) +- 人体识别(ai_body_recognition) +- 身份证识别(ai_id_card_ocr) +- 行驶证识别(ai_license_rec) +- 游戏场景识别(ai_game_rec) +- 商品抠图(goods_matting) +- 图片标签(detect_label) +- OCR 通用文字识别(ocr_process) +- 车辆识别(detect_car) +- 图片质量评估(assess_quality) +- 二维码识别(qrcode_detect) +- 二维码生成(qrcode_generate) +- 图片超分辨率(super_resolution) +- 通用抠图(pic_matting) +- 人像抠图(portrait_matting) + +使用 ci_process 通用方法调用 AI 功能,通过 CiProcess 参数区分不同处理类型。 +""" +import json +import os + +from .ci_helpers import init_cos_client, handle_cos_error, print_result, save_response_to_file + + +def _ci_process_stream(client, bucket, key, ci_process, params=None, local_path=None): + """通用的 ci_process 调用(返回图片流的场景) + + Args: + client: CosS3Client 实例 + bucket: 存储桶名称 + key: COS 对象键 + ci_process: CI 处理类型名称 + params: (可选) 额外 query 参数 + local_path: (可选) 本地保存路径 + + Returns: + tuple: (dict 结果信息, dict response headers) + """ + resp_headers, response = client.ci_process( + Bucket=bucket, + Key=key, + CiProcess=ci_process, + Params=params or {}, + Stream=True, + NeedHeader=True, + ) + + # 某些 CI 方法(如 face-effect)在错误时返回 dict 而非 StreamBody + if isinstance(response, dict): + # 如果是错误响应,直接返回 + return response, resp_headers + + if local_path: + total_bytes = save_response_to_file(response, local_path) + return { + "Status": "Success", + "Output": os.path.abspath(local_path), + "ContentLength": str(total_bytes), + }, resp_headers + else: + return { + "Status": "Success", + "Message": "Stream response received but no local_path specified", + }, resp_headers + + +def _ci_process_json(client, bucket, key, ci_process, params=None): + """通用的 ci_process 调用(返回 JSON 的场景) + + Args: + client: CosS3Client 实例 + bucket: 存储桶名称 + key: COS 对象键 + ci_process: CI 处理类型名称 + params: (可选) 额外 query 参数 + + Returns: + tuple: (dict API 返回的 JSON 数据, dict response headers) + """ + resp_headers, response = client.ci_process( + Bucket=bucket, + Key=key, + CiProcess=ci_process, + Params=params or {}, + NeedHeader=True, + ) + return response, resp_headers + + +# --------------------------------------------------------------------------- +# AI 图像处理(返回图片流) +# --------------------------------------------------------------------------- + +def ai_image_coloring(args, parsed_globals): + """AI 图片上色""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + result, resp_headers = _ci_process_stream( + client, args["bucket"], key, + "AIImageColoring", params, + args.get("local_path"), + ) + print_result(result, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def ai_enhance_image(args, parsed_globals): + """AI 图片增强""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("denoise"): + params["denoise"] = args["denoise"] + if args.get("sharpen"): + params["sharpen"] = args["sharpen"] + if args.get("url"): + params["detect-url"] = args["url"] + result, resp_headers = _ci_process_stream( + client, args["bucket"], key, + "AIEnhanceImage", params, + args.get("local_path"), + ) + print_result(result, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def ai_image_crop(args, parsed_globals): + """AI 智能裁剪""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = { + "width": args["width"], + "height": args["height"], + } + if args.get("fixed"): + params["fixed"] = args["fixed"] + if args.get("url"): + params["detect-url"] = args["url"] + result, resp_headers = _ci_process_stream( + client, args["bucket"], key, + "AIImageCrop", params, + args.get("local_path"), + ) + print_result(result, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def ai_image_repair(args, parsed_globals): + """AI 图片修复""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("mask_key"): + params["MaskPic"] = args["mask_key"] + elif args.get("mask_url"): + params["MaskPic"] = args["mask_url"] + result, resp_headers = _ci_process_stream( + client, args["bucket"], key, + "ImageRepair", params, + args.get("local_path"), + ) + print_result(result, resp_headers) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 人脸与人体 +# --------------------------------------------------------------------------- + +def ai_detect_face(args, parsed_globals): + """AI 人脸检测""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("max_face_num"): + params["max-face-num"] = args["max_face_num"] + response, resp_headers = _ci_process_json( + client, args["bucket"], key, + "DetectFace", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def ai_face_effect(args, parsed_globals): + """AI 人脸特效""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {"type": args["effect_type"]} + if args.get("url"): + params["detect-url"] = args["url"] + result, resp_headers = _ci_process_stream( + client, args["bucket"], key, + "face-effect", params, + args.get("local_path"), + ) + print_result(result, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def ai_body_recognition(args, parsed_globals): + """AI 人体识别""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + response, resp_headers = _ci_process_json( + client, args["bucket"], key, + "AIBodyRecognition", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# OCR 识别 +# --------------------------------------------------------------------------- + +def ai_id_card_ocr(args, parsed_globals): + """身份证 OCR 识别""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("card_side"): + params["CardSide"] = args["card_side"] + if args.get("url"): + params["detect-url"] = args["url"] + response, resp_headers = _ci_process_json( + client, args["bucket"], key, + "IDCardOCR", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def ai_license_rec(args, parsed_globals): + """行驶证/驾驶证 OCR 识别""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("card_type"): + params["CardType"] = args["card_type"] + if args.get("url"): + params["detect-url"] = args["url"] + response, resp_headers = _ci_process_json( + client, args["bucket"], key, + "LicenseRec", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def ai_game_rec(args, parsed_globals): + """游戏场景识别""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + response, resp_headers = _ci_process_json( + client, args["bucket"], key, + "GameRec", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def ocr_process(args, parsed_globals): + """通用 OCR 文字识别""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key and not args.get("url"): + raise ValueError("必须指定 key 或 url 参数") + + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + if args.get("language_type"): + params["language-type"] = args["language_type"] + if args.get("ispdf"): + params["ispdf"] = args["ispdf"] + if args.get("pdf_pagenumber"): + params["pdf-pagenumber"] = args["pdf_pagenumber"] + + response, resp_headers = _ci_process_json( + client, args["bucket"], key or "", + "OCR", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 图片识别(SDK 已内置的方法) +# --------------------------------------------------------------------------- + +def detect_label(args, parsed_globals): + """图片标签识别""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key and not args.get("url"): + raise ValueError("必须指定 key 或 url 参数") + + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + + response, resp_headers = _ci_process_json( + client, args["bucket"], key or "", + "detect-label", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def detect_car(args, parsed_globals): + """车辆识别""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key and not args.get("url"): + raise ValueError("必须指定 key 或 url 参数") + + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + + response, resp_headers = _ci_process_json( + client, args["bucket"], key or "", + "DetectCar", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def assess_quality(args, parsed_globals): + """图片质量评估""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key and not args.get("url"): + raise ValueError("必须指定 key 或 url 参数") + + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + + response, resp_headers = _ci_process_json( + client, args["bucket"], key or "", + "AssessQuality", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 二维码 +# --------------------------------------------------------------------------- + +def qrcode_detect(args, parsed_globals): + """二维码识别""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key and not args.get("url"): + raise ValueError("必须指定 key 或 url 参数") + + cover = int(args["cover"]) if args.get("cover") else 0 + params = {"cover": str(cover)} + if args.get("url"): + params["detect-url"] = args["url"] + + response, resp_headers = _ci_process_json( + client, args["bucket"], key or "", + "QRcode", params, + ) + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def qrcode_generate(args, parsed_globals): + """二维码生成""" + try: + client = init_cos_client(parsed_globals) + kwargs = { + "Bucket": args["bucket"], + "QrcodeContent": args["text"], + } + if args.get("width"): + kwargs["Width"] = int(args["width"]) + if args.get("mode"): + kwargs["Mode"] = int(args["mode"]) + response = client.ci_qrcode_generate(**kwargs) + if args.get("local_path") and response.get("ResultImage"): + import base64 + img_data = base64.b64decode(response["ResultImage"]) + output_dir = os.path.dirname(args["local_path"]) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + with open(args["local_path"], "wb") as f: + f.write(img_data) + response["Output"] = os.path.abspath(args["local_path"]) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 图片增强处理(SDK 已内置的方法) +# --------------------------------------------------------------------------- + +def super_resolution(args, parsed_globals): + """图片超分辨率""" + try: + client = init_cos_client(parsed_globals) + result, resp_headers = _ci_process_stream( + client, args["bucket"], args["key"], + "AIImageSuperResolution", {}, + args.get("local_path"), + ) + print_result(result, resp_headers) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 抠图(使用 ci_process 通用方法) +# --------------------------------------------------------------------------- + +def goods_matting(args, parsed_globals): + """商品抠图""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + result, resp_headers = _ci_process_stream( + client, args["bucket"], key, + "GoodsMatting", params, + args.get("local_path"), + ) + print_result(result, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def pic_matting(args, parsed_globals): + """通用抠图""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + result, resp_headers = _ci_process_stream( + client, args["bucket"], key, + "AIPicMatting", params, + args.get("local_path"), + ) + print_result(result, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def portrait_matting(args, parsed_globals): + """人像抠图""" + try: + client = init_cos_client(parsed_globals) + key = args.get("key") + if not key: + raise ValueError("必须指定 key 参数") + params = {} + if args.get("url"): + params["detect-url"] = args["url"] + result, resp_headers = _ci_process_stream( + client, args["bucket"], key, + "AIPortraitMatting", params, + args.get("local_path"), + ) + print_result(result, resp_headers) + except Exception as e: + handle_cos_error(e) diff --git a/tccli/plugins/ci/auditing.py b/tccli/plugins/ci/auditing.py new file mode 100644 index 0000000000..bc484bdd43 --- /dev/null +++ b/tccli/plugins/ci/auditing.py @@ -0,0 +1,467 @@ +# -*- coding: utf-8 -*- +""" +内容审核模块 + +提供数据万象内容审核功能的 CLI 命令,包括: +- 图片审核(同步批量) +- 视频审核(提交 + 查询) +- 音频审核(提交 + 查询) +- 文本审核(提交 + 查询) +- 文档审核(提交 + 查询) +- 网页审核(提交 + 查询) +- 直播审核(提交 + 取消) +- 病毒检测(提交 + 查询) +- 举报不良内容 +""" +from .ci_helpers import init_cos_client, handle_cos_error, print_result + + +def _parse_detect_type(detect_type_str): + """将逗号分隔的审核类型字符串转为 SDK 要求的整数位掩码 + + SDK DetectType 是一个整数位掩码: + CiDetectType.PORN = 1 + CiDetectType.TERRORIST = 2 + CiDetectType.POLITICS = 4 + CiDetectType.ADS = 8 + + 也接受直接传数字。 + """ + type_map = { + "porn": 1, + "terrorist": 2, + "terrorism": 2, + "politics": 4, + "ads": 8, + } + if not detect_type_str: + return None # 不设置,让 SDK 使用默认值 + + # 如果是纯数字,直接返回 + try: + return int(detect_type_str) + except ValueError: + pass + + result = 0 + for part in detect_type_str.replace("|", ",").split(","): + part = part.strip().lower() + if part in type_map: + result |= type_map[part] + return result if result > 0 else None + + +# --------------------------------------------------------------------------- +# 图片审核 +# --------------------------------------------------------------------------- + +def auditing_image(args, parsed_globals): + """图片批量审核(同步) + + ci_auditing_image_batch(self, Bucket, Input, DetectType=None, BizType=None, ...) + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + + # 构建审核输入列表 + inputs = [] + keys = args.get("key") + if keys: + for k in keys.split(","): + k = k.strip() + if k: + inputs.append({"Object": k}) + + urls = args.get("url") + if urls: + for u in urls.split(","): + u = u.strip() + if u: + inputs.append({"Url": u}) + + if not inputs: + raise ValueError("必须指定 key 或 url 参数") + + kwargs = { + "Bucket": bucket, + "Input": inputs, + } + + detect_type = _parse_detect_type(args.get("detect_type")) + if detect_type is not None: + kwargs["DetectType"] = detect_type + + biz_type = args.get("biz_type") + if biz_type: + kwargs["BizType"] = biz_type + + response = client.ci_auditing_image_batch(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 视频审核 +# --------------------------------------------------------------------------- + +def auditing_video_submit(args, parsed_globals): + """提交视频审核任务 + + ci_auditing_video_submit(self, Bucket, Key, DetectType=None, Url=None, + Callback=None, Mode='Interval', Count=100, BizType=None, ...) + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + + kwargs = {"Bucket": bucket} + + # Key 是位置参数,必须提供(可以为空字符串,用 Url 替代) + key = args.get("key") or "" + kwargs["Key"] = key + + if args.get("url"): + kwargs["Url"] = args["url"] + + detect_type = _parse_detect_type(args.get("detect_type")) + if detect_type is not None: + kwargs["DetectType"] = detect_type + + if args.get("biz_type"): + kwargs["BizType"] = args["biz_type"] + if args.get("snapshot_mode"): + kwargs["Mode"] = args["snapshot_mode"] + if args.get("snapshot_count"): + kwargs["Count"] = int(args["snapshot_count"]) + + response = client.ci_auditing_video_submit(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def auditing_video_query(args, parsed_globals): + """查询视频审核任务结果""" + try: + client = init_cos_client(parsed_globals) + response = client.ci_auditing_video_query( + Bucket=args["bucket"], + JobID=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 音频审核 +# --------------------------------------------------------------------------- + +def auditing_audio_submit(args, parsed_globals): + """提交音频审核任务 + + ci_auditing_audio_submit(self, Bucket, Key, DetectType=None, Url=None, + BizType=None, ...) + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + + kwargs = {"Bucket": bucket} + + # Key 是位置参数 + key = args.get("key") or "" + kwargs["Key"] = key + + if args.get("url"): + kwargs["Url"] = args["url"] + + detect_type = _parse_detect_type(args.get("detect_type")) + if detect_type is not None: + kwargs["DetectType"] = detect_type + + if args.get("biz_type"): + kwargs["BizType"] = args["biz_type"] + + response = client.ci_auditing_audio_submit(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def auditing_audio_query(args, parsed_globals): + """查询音频审核任务结果""" + try: + client = init_cos_client(parsed_globals) + response = client.ci_auditing_audio_query( + Bucket=args["bucket"], + JobID=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 文本审核 +# --------------------------------------------------------------------------- + +def auditing_text_submit(args, parsed_globals): + """提交文本审核任务 + + ci_auditing_text_submit(self, Bucket, Key=None, DetectType=None, + Content=None, Callback=None, BizType=None, Url=None, ...) + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + + kwargs = {"Bucket": bucket} + + if args.get("key"): + kwargs["Key"] = args["key"] + if args.get("url"): + kwargs["Url"] = args["url"] + if args.get("content"): + # SDK 要求 Content 是 bytes 类型 + content = args["content"] + if isinstance(content, str): + content = content.encode("utf-8") + kwargs["Content"] = content + + if not (args.get("key") or args.get("url") or args.get("content")): + raise ValueError("必须指定 key、url 或 content 参数") + + detect_type = _parse_detect_type(args.get("detect_type")) + if detect_type is not None: + kwargs["DetectType"] = detect_type + + if args.get("biz_type"): + kwargs["BizType"] = args["biz_type"] + + response = client.ci_auditing_text_submit(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def auditing_text_query(args, parsed_globals): + """查询文本审核任务结果""" + try: + client = init_cos_client(parsed_globals) + response = client.ci_auditing_text_query( + Bucket=args["bucket"], + JobID=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 文档审核 +# --------------------------------------------------------------------------- + +def auditing_document_submit(args, parsed_globals): + """提交文档审核任务 + + ci_auditing_document_submit(self, Bucket, Url=None, DetectType=None, + Key=None, Type=None, BizType=None, ...) + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + + kwargs = {"Bucket": bucket} + + if args.get("key"): + kwargs["Key"] = args["key"] + if args.get("url"): + kwargs["Url"] = args["url"] + + if not (args.get("key") or args.get("url")): + raise ValueError("必须指定 key 或 url 参数") + + if args.get("doc_type"): + kwargs["Type"] = args["doc_type"] + + detect_type = _parse_detect_type(args.get("detect_type")) + if detect_type is not None: + kwargs["DetectType"] = detect_type + + if args.get("biz_type"): + kwargs["BizType"] = args["biz_type"] + + response = client.ci_auditing_document_submit(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def auditing_document_query(args, parsed_globals): + """查询文档审核任务结果""" + try: + client = init_cos_client(parsed_globals) + response = client.ci_auditing_document_query( + Bucket=args["bucket"], + JobID=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 网页审核 +# --------------------------------------------------------------------------- + +def auditing_webpage_submit(args, parsed_globals): + """提交网页审核任务 + + ci_auditing_html_submit(self, Bucket, Url, DetectType=None, + ReturnHighlightHtml=False, BizType=None, ...) + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + + kwargs = { + "Bucket": bucket, + "Url": args["url"], + } + + detect_type = _parse_detect_type(args.get("detect_type")) + if detect_type is not None: + kwargs["DetectType"] = detect_type + + if args.get("biz_type"): + kwargs["BizType"] = args["biz_type"] + + response = client.ci_auditing_html_submit(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def auditing_webpage_query(args, parsed_globals): + """查询网页审核任务结果""" + try: + client = init_cos_client(parsed_globals) + response = client.ci_auditing_html_query( + Bucket=args["bucket"], + JobID=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 直播审核 +# --------------------------------------------------------------------------- + +def auditing_live_submit(args, parsed_globals): + """提交直播审核任务""" + try: + client = init_cos_client(parsed_globals) + kwargs = { + "Bucket": args["bucket"], + "Url": args["url"], + } + if args.get("biz_type"): + kwargs["BizType"] = args["biz_type"] + if args.get("callback"): + kwargs["Callback"] = args["callback"] + response = client.ci_auditing_live_video_submit(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def auditing_live_cancel(args, parsed_globals): + """取消直播审核任务""" + try: + client = init_cos_client(parsed_globals) + response = client.ci_auditing_live_video_cancle( + Bucket=args["bucket"], + JobID=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 病毒检测 +# --------------------------------------------------------------------------- + +def auditing_virus_submit(args, parsed_globals): + """提交病毒检测任务 + + ci_auditing_virus_submit(self, Bucket, Key=None, Url=None, Callback=None, ...) + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + + kwargs = {"Bucket": bucket} + + if args.get("key"): + kwargs["Key"] = args["key"] + if args.get("url"): + kwargs["Url"] = args["url"] + + if not (args.get("key") or args.get("url")): + raise ValueError("必须指定 key 或 url 参数") + + response = client.ci_auditing_virus_submit(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def auditing_virus_query(args, parsed_globals): + """查询病毒检测任务结果""" + try: + client = init_cos_client(parsed_globals) + response = client.ci_auditing_virus_query( + Bucket=args["bucket"], + JobID=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# --------------------------------------------------------------------------- +# 审核结果反馈 +# --------------------------------------------------------------------------- + +def auditing_report_badcase(args, parsed_globals): + """审核结果反馈 + + ci_auditing_report_badcase(self, Bucket, ContentType, Label, + SuggestedLabel, Text=None, Url=None, ...) + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + + kwargs = { + "Bucket": bucket, + "ContentType": int(args["content_type"]), + "Label": args["label"], + "SuggestedLabel": args.get("suggestion_label") or "Block", + } + + if args.get("url"): + kwargs["Url"] = args["url"] + if args.get("text"): + kwargs["Text"] = args["text"] + + response = client.ci_auditing_report_badcase(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) diff --git a/tccli/plugins/ci/ci_helpers.py b/tccli/plugins/ci/ci_helpers.py new file mode 100644 index 0000000000..026f26d4d6 --- /dev/null +++ b/tccli/plugins/ci/ci_helpers.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +""" +CI(数据万象)插件公共工具模块 + +提供以下功能: +- init_cos_client: COS 客户端初始化(数据万象复用 COS SDK 客户端) +- handle_cos_error: 统一错误处理 +- print_result: JSON 格式化输出 +- save_response_to_file: 流式写入本地文件 +""" +import json +import os +import sys + +from qcloud_cos import CosConfig, CosS3Client, CosServiceError + + +def _load_credential_from_profile(parsed_globals): + """从 TCCLI 配置文件或环境变量中加载认证信息 + + 当 parsed_globals 中的 secretId/secretKey 为 None 时(即用户未通过命令行 + --secretId/--secretKey 传入),自动从以下来源按优先级读取: + 1. ~/.tccli/.credential 文件 + 2. 环境变量 TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY + + 同时加载 region 配置(如果命令行未指定)。 + + Args: + parsed_globals: TCCLI 传入的全局参数字典(会被就地修改) + + Returns: + 修改后的 parsed_globals + """ + secret_id = parsed_globals.get("secretId") + secret_key = parsed_globals.get("secretKey") + + # 如果命令行已经传入了密钥,直接返回 + if secret_id and secret_key: + return parsed_globals + + # 确定 profile + profile = parsed_globals.get("profile") or os.environ.get("TCCLI_PROFILE", "default") + configure_path = os.path.join(os.path.expanduser("~"), ".tccli") + + # 尝试从 credential 文件加载 + cred_file = os.path.join(configure_path, profile + ".credential") + cred = {} + if os.path.isfile(cred_file): + try: + with open(cred_file, "r") as f: + cred = json.load(f) + except (json.JSONDecodeError, IOError): + pass + + if not secret_id: + parsed_globals["secretId"] = ( + cred.get("secretId") + or os.environ.get("TENCENTCLOUD_SECRET_ID") + ) + if not secret_key: + parsed_globals["secretKey"] = ( + cred.get("secretKey") + or os.environ.get("TENCENTCLOUD_SECRET_KEY") + ) + if not parsed_globals.get("token"): + parsed_globals["token"] = ( + cred.get("token") + or os.environ.get("TENCENTCLOUD_TOKEN") + ) + + # 尝试从 configure 文件加载 region + if not parsed_globals.get("region"): + conf_file = os.path.join(configure_path, profile + ".configure") + if os.path.isfile(conf_file): + try: + with open(conf_file, "r") as f: + conf = json.load(f) + sys_param = conf.get("_sys_param", {}) + parsed_globals["region"] = ( + sys_param.get("region") + or os.environ.get("TENCENTCLOUD_REGION") + ) + except (json.JSONDecodeError, IOError): + pass + + # 最终校验 + if not parsed_globals.get("secretId") or not parsed_globals.get("secretKey"): + raise ValueError( + "SecretId and SecretKey is Required! " + "请通过 tccli configure 配置密钥,或通过 --secretId/--secretKey 参数传入," + "或设置环境变量 TENCENTCLOUD_SECRET_ID/TENCENTCLOUD_SECRET_KEY" + ) + + return parsed_globals + + +def init_cos_client(parsed_globals): + """初始化 COS 客户端 + + 从 parsed_globals 中读取认证信息和区域配置,创建 CosS3Client 实例。 + 如果命令行未传入密钥,会自动从 TCCLI 配置文件或环境变量中加载。 + 数据万象 CI 的 API 底层复用 COS SDK 的客户端。 + + Args: + parsed_globals: TCCLI 传入的全局参数字典,包含: + - secretId: 腾讯云 SecretId + - secretKey: 腾讯云 SecretKey + - token: 临时密钥 Token(可选) + - region: 地域(可选,默认 ap-guangzhou) + - endpoint: 自定义 Endpoint(可选) + + Returns: + CosS3Client 实例 + """ + # 确保密钥已加载(从配置文件或环境变量) + _load_credential_from_profile(parsed_globals) + + config = CosConfig( + Region=parsed_globals.get("region") or "ap-guangzhou", + SecretId=parsed_globals["secretId"], + SecretKey=parsed_globals["secretKey"], + Token=parsed_globals.get("token"), + Endpoint=parsed_globals.get("endpoint"), + ) + return CosS3Client(config) + + +def handle_cos_error(e): + """统一错误处理 + + 将 COS SDK 异常和通用异常格式化为 JSON 输出到 stderr 并退出。 + + Args: + e: 捕获到的异常 + """ + if isinstance(e, CosServiceError): + result = { + "Error": { + "Code": e.get_error_code(), + "Message": e.get_error_msg(), + "RequestId": e.get_request_id(), + "StatusCode": e.get_status_code(), + } + } + else: + result = {"Error": {"Code": "ClientError", "Message": str(e)}} + print(json.dumps(result, indent=2, ensure_ascii=False), file=sys.stderr) + sys.exit(1) + + +def _extract_request_id(headers): + """从 HTTP 响应头中提取 RequestId + + Args: + headers: HTTP 响应头字典 + + Returns: + dict: 包含 RequestId 信息的字典(可能为空) + """ + if not headers or not isinstance(headers, dict): + return {} + result = {} + ci_req_id = headers.get("x-ci-request-id") + cos_req_id = headers.get("x-cos-request-id") + if ci_req_id: + result["RequestId"] = ci_req_id + if cos_req_id: + result["CosRequestId"] = cos_req_id + return result + + +def print_result(data, headers=None): + """JSON 格式化输出,可选附带 RequestId + + Args: + data: 要输出的字典或列表 + headers: (可选) HTTP 响应头字典,用于提取 RequestId + """ + if isinstance(data, dict) and headers: + req_ids = _extract_request_id(headers) + if req_ids: + # 将 RequestId 注入到输出中,放在最前面 + data = {**req_ids, **data} + print(json.dumps(data, indent=2, ensure_ascii=False)) + + +def save_response_to_file(response, output_path): + """将 COS 响应体流式写入本地文件 + + 支持两种响应格式: + 1. dict: 标准 COS 响应,包含 Body 字段(如 get_snapshot) + 2. StreamBody: ci_process(Stream=True) 直接返回 StreamBody 对象 + + Args: + response: COS SDK 返回的 response 对象 + output_path: 本地保存路径 + + Returns: + 写入的字节数 + """ + from qcloud_cos.cos_client import StreamBody + + # 确保输出目录存在 + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + total_bytes = 0 + with open(output_path, "wb") as f: + if isinstance(response, StreamBody): + # ci_process(Stream=True) 直接返回 StreamBody + for chunk in response.get_stream(1024 * 1024): + f.write(chunk) + total_bytes += len(chunk) + elif isinstance(response, dict) and "Body" in response: + # 标准 COS 响应格式 + body = response["Body"] + if isinstance(body, StreamBody): + for chunk in body.get_stream(1024 * 1024): + f.write(chunk) + total_bytes += len(chunk) + else: + for chunk in body.get_raw_stream().stream(1024 * 1024): + f.write(chunk) + total_bytes += len(chunk) + elif isinstance(response, bytes): + f.write(response) + total_bytes = len(response) + else: + raise ValueError(f"Unsupported response type: {type(response)}") + return total_bytes diff --git a/tccli/plugins/ci/doc_process.py b/tccli/plugins/ci/doc_process.py new file mode 100644 index 0000000000..534337c1bf --- /dev/null +++ b/tccli/plugins/ci/doc_process.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +""" +文档处理模块 + +提供数据万象文档处理功能的 CLI 命令,包括: +- 同步文档预览(转图片下载到本地) +- HTML 文档预览(获取预览 URL) +- 异步文档转码任务(提交 + 查询 + 列出) +""" +import os + +from .ci_helpers import init_cos_client, handle_cos_error, print_result, save_response_to_file + + +def doc_preview(args, parsed_globals): + """同步文档预览(转为图片) + + 将 COS 上的文档(PDF/Word/Excel/PPT 等)转为图片并下载到本地。 + + Args: + args: + - bucket: 存储桶名称 + - key: 文档在 COS 上的对象键 + - page: (可选) 预览页码,从 1 开始,默认 1 + - src_type: (可选) 源文件类型,如 pdf/docx/xlsx/pptx + - image_type: (可选) 输出图片类型 png/jpg,默认 png + - dsttype: (可选) 输出格式 png/jpg/pdf,默认 png + - output: (可选) 本地保存路径 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + kwargs = { + "Bucket": args["bucket"], + "Key": args["key"], + } + + page = args.get("page") or "1" + kwargs["Page"] = int(page) + + if args.get("src_type"): + kwargs["SrcType"] = args["src_type"] + if args.get("image_type"): + kwargs["ImageParams"] = "imageMogr2/format/" + args["image_type"] + if args.get("dsttype"): + kwargs["DstType"] = args["dsttype"] + + response = client.ci_doc_preview_process(**kwargs) + + # 确定输出路径 + ext = args.get("dsttype") or args.get("image_type") or "png" + base = os.path.splitext(os.path.basename(args["key"]))[0] + default_name = f"{base}_page{page}.{ext}" + output = args.get("local_path") or default_name + + total_bytes = save_response_to_file(response, output) + + print_result({ + "Status": "Success", + "Output": os.path.abspath(output), + "ContentType": response.get("Content-Type", ""), + "ContentLength": str(total_bytes), + "Page": page, + "TotalPage": response.get("X-Total-Page", ""), + "TotalSheet": response.get("X-Total-Sheet", ""), + }, response) + except Exception as e: + handle_cos_error(e) + + +def doc_preview_html(args, parsed_globals): + """HTML 文档在线预览 + + 获取文档的 HTML 预览内容,保存为本地 HTML 文件。 + + Args: + args: + - bucket: 存储桶名称 + - key: 文档在 COS 上的对象键 + - src_type: (可选) 源文件类型 + - output: (可选) 保存 HTML 内容到本地的路径,默认自动生成 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + kwargs = { + "Bucket": args["bucket"], + "Key": args["key"], + } + + if args.get("src_type"): + kwargs["SrcType"] = args["src_type"] + + response = client.ci_doc_preview_html_process(**kwargs) + + # 确定输出路径:未指定时自动生成 + if args.get("local_path"): + output = args["local_path"] + else: + base = os.path.splitext(os.path.basename(args["key"]))[0] + output = f"{base}_preview.html" + + total_bytes = save_response_to_file(response, output) + print_result({ + "Status": "Success", + "Output": os.path.abspath(output), + "ContentType": "text/html", + "ContentLength": str(total_bytes), + }, response) + except Exception as e: + handle_cos_error(e) + + +def doc_job_submit(args, parsed_globals): + """提交异步文档转码任务 + + 将文档转码为指定格式(图片/PDF 等),结果存储到 COS。 + + Args: + args: + - bucket: 存储桶名称 + - key: 文档在 COS 上的对象键 + - output_bucket: (可选) 输出存储桶,默认同 bucket + - output_key: 输出文件的 COS 对象键 + - src_type: (可选) 源文件类型 + - tgt_type: (可选) 目标文件类型 png/jpg/pdf,默认 png + - start_page: (可选) 起始页码,默认 1 + - end_page: (可选) 结束页码,默认 -1(所有页) + - sheet_id: (可选) Sheet 编号(Excel 文件) + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + doc_process = {} + if args.get("src_type"): + doc_process["SrcType"] = args["src_type"] + doc_process["TgtType"] = args.get("tgt_type") or "png" + if args.get("start_page"): + doc_process["StartPage"] = int(args["start_page"]) + if args.get("end_page"): + doc_process["EndPage"] = int(args["end_page"]) + if args.get("sheet_id"): + doc_process["SheetId"] = int(args["sheet_id"]) + + kwargs = { + "Bucket": args["bucket"], + "InputObject": args["key"], + "OutputBucket": args.get("output_bucket") or args["bucket"], + "OutputRegion": parsed_globals.get("region") or "ap-guangzhou", + "OutputObject": args["output_key"], + } + + if doc_process.get("SrcType"): + kwargs["SrcType"] = doc_process["SrcType"] + if doc_process.get("TgtType"): + kwargs["TgtType"] = doc_process["TgtType"] + if doc_process.get("StartPage"): + kwargs["StartPage"] = doc_process["StartPage"] + if doc_process.get("EndPage"): + kwargs["EndPage"] = doc_process["EndPage"] + if doc_process.get("SheetId"): + kwargs["SheetId"] = doc_process["SheetId"] + + response = client.ci_create_doc_job(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def doc_job_query(args, parsed_globals): + """查询异步文档转码任务 + + Args: + args: + - bucket: 存储桶名称 + - job_id: 任务 ID + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + response = client.ci_get_doc_job( + Bucket=args["bucket"], + JobID=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def doc_job_list(args, parsed_globals): + """列出异步文档转码任务 + + Args: + args: + - bucket: 存储桶名称 + - size: (可选) 每页数量,默认 10 + - page: (可选) 页码,默认 1 + - status: (可选) 任务状态过滤 All/Submitted/Running/Success/Failed/Pause/Cancel + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + kwargs = { + "Bucket": args["bucket"], + } + if args.get("size"): + kwargs["Size"] = int(args["size"]) + if args.get("page"): + kwargs["Page"] = int(args["page"]) + if args.get("status"): + kwargs["States"] = args["status"] + + response = client.ci_list_doc_jobs(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) diff --git a/tccli/plugins/ci/file_process.py b/tccli/plugins/ci/file_process.py new file mode 100644 index 0000000000..8deb34fc2b --- /dev/null +++ b/tccli/plugins/ci/file_process.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +""" +文件处理模块 + +提供数据万象文件处理功能的 CLI 命令,包括: +- 同步文件哈希计算 +- 异步文件哈希计算(提交 + 查询) +- 文件解压缩 +- 文件压缩 +- 压缩包预览 +- 查询文件处理任务 +""" +import os + +from .ci_helpers import init_cos_client, handle_cos_error, print_result, save_response_to_file + + +def file_hash(args, parsed_globals): + """同步计算文件哈希值 + + file_hash(self, Bucket, Key, Type, AddToHeader=False, ...) + """ + try: + client = init_cos_client(parsed_globals) + + response = client.file_hash( + Bucket=args["bucket"], + Key=args["key"], + Type=args["hash_type"], + AddToHeader=( + args.get("add_to_header", "").lower() == "true" + if args.get("add_to_header") else False + ), + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def file_hash_job_submit(args, parsed_globals): + """提交异步文件哈希计算任务 + + ci_create_file_hash_job(self, Bucket, InputObject, FileHashCodeConfig, + QueueId=None, CallBack=None, ...) + """ + try: + client = init_cos_client(parsed_globals) + + hash_config = { + "Type": args["hash_type"], + } + if args.get("add_to_header") and args["add_to_header"].lower() == "true": + hash_config["AddToHeader"] = "true" + + response = client.ci_create_file_hash_job( + Bucket=args["bucket"], + InputObject=args["key"], + FileHashCodeConfig=hash_config, + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def file_uncompress_submit(args, parsed_globals): + """提交文件解压缩任务 + + ci_create_file_uncompress_job(self, Bucket, InputObject, OutputBucket, + OutputRegion, FileUncompressConfig, ...) + """ + try: + client = init_cos_client(parsed_globals) + + uncompress_config = {} + if args.get("password"): + uncompress_config["UnCompressKey"] = args["password"] + if args.get("output_prefix"): + uncompress_config["Prefix"] = args["output_prefix"] + + response = client.ci_create_file_uncompress_job( + Bucket=args["bucket"], + InputObject=args["key"], + OutputBucket=args.get("output_bucket") or args["bucket"], + OutputRegion=parsed_globals.get("region") or "ap-guangzhou", + FileUncompressConfig=uncompress_config, + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def file_compress_submit(args, parsed_globals): + """提交文件压缩任务 + + ci_create_file_compress_job(self, Bucket, OutputBucket, OutputRegion, + OutputObject, FileCompressConfig, ...) + """ + try: + client = init_cos_client(parsed_globals) + + key_list = [k.strip() for k in args["keys"].split(",") if k.strip()] + + compress_config = { + "Flatten": "0", + "Format": args.get("compress_format") or "zip", + "Key": key_list, + } + if args.get("prefix"): + compress_config["Prefix"] = args["prefix"] + + response = client.ci_create_file_compress_job( + Bucket=args["bucket"], + OutputBucket=args["bucket"], + OutputRegion=parsed_globals.get("region") or "ap-guangzhou", + OutputObject=args["output_key"], + FileCompressConfig=compress_config, + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def file_zip_preview(args, parsed_globals): + """预览压缩包内容(列出文件列表)""" + try: + client = init_cos_client(parsed_globals) + + response = client.ci_file_zip_preview( + Bucket=args["bucket"], + Key=args["key"], + NeedHeader=True, + ) + # ci_file_zip_preview 支持 NeedHeader,返回 (headers, data) 元组 + if isinstance(response, tuple): + resp_headers, data = response + print_result(data, resp_headers) + else: + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def file_process_jobs_query(args, parsed_globals): + """查询文件处理任务""" + try: + client = init_cos_client(parsed_globals) + + response = client.ci_get_file_process_jobs( + Bucket=args["bucket"], + JobIDs=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) diff --git a/tccli/plugins/ci/image_process.py b/tccli/plugins/ci/image_process.py new file mode 100644 index 0000000000..324bec204a --- /dev/null +++ b/tccli/plugins/ci/image_process.py @@ -0,0 +1,444 @@ +# -*- coding: utf-8 -*- +""" +图片处理模块 + +提供数据万象图片处理全部功能的 CLI 命令,包括: + +基础处理: +- image_process: 图片下载时实时处理 +- image_process_saveas: 图片云上处理另存 +- image_upload_process: 图片上传时处理 +- image_info: 获取图片基本信息 + +补充功能: +- image_exif_info: 获取图片 EXIF 信息 +- image_ave_color: 获取图片主色调 +- image_inspect: 异常图片检测 +- image_style_add: 添加图片样式 +- image_style_get: 查询图片样式 +- image_style_delete: 删除图片样式 +""" +import json +import os + +from .ci_helpers import init_cos_client, handle_cos_error, print_result + + +# ===================================================================== +# 基础处理 +# ===================================================================== + +def image_process(args, parsed_globals): + """下载时实时处理 + + 调用 SDK 的 ci_get_object 方法,对图片进行实时处理后下载到本地。 + 原图不变。 + + 支持所有数据万象处理规则:缩放、裁剪、旋转、格式转换、水印、 + 管道操作(多步处理用 | 连接)等。 + + Args: + args: 命令参数字典 + - bucket: 存储桶名称 + - key: 图片在 COS 上的对象键 + - rule: 数据万象处理规则(如 imageView2/2/w/800/h/600) + - local_path: (可选) 本地保存路径,默认保存到当前目录 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + key = args["key"] + rule = args["rule"] + + # 确定输出路径 + output = args.get("local_path") or os.path.basename(key) + + # 确保输出目录存在 + output_dir = os.path.dirname(output) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + # 使用 SDK 原生的 ci_get_object 方法:下载时处理 + response = client.ci_get_object( + Bucket=bucket, + Key=key, + DestImagePath=output, + Rule=rule, + ) + + file_size = os.path.getsize(output) if os.path.exists(output) else 0 + + # response 就是 headers dict + print_result({ + "Status": "Success", + "Output": os.path.abspath(output), + "ContentType": response.get("Content-Type", ""), + "ContentLength": str(file_size), + }, response) + except Exception as e: + handle_cos_error(e) + + +def image_process_saveas(args, parsed_globals): + """云上数据处理 + + 通过 POST 请求调用数据万象 ci_image_process 接口, + 对已存储在 COS 上的图片进行处理,结果另存为新的对象。 + 原图保持不变。 + + Args: + args: 命令参数字典 + - bucket: 存储桶名称 + - key: 原图在 COS 上的对象键 + - rule: 数据万象处理规则 + - savekey: 处理后图片在 COS 上的存储路径 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + key = args["key"] + rule = args["rule"] + savekey = args["savekey"] + + pic_operations = json.dumps({ + "is_pic_info": 1, + "rules": [{"fileid": savekey, "rule": rule}] + }) + + response, data = client.ci_image_process( + Bucket=bucket, + Key=key, + PicOperations=pic_operations, + ) + + result = { + "Status": "Success", + "RequestId": response.get("x-cos-request-id", ""), + } + + # 原图信息 + original = data.get("OriginalInfo", {}).get("ImageInfo", {}) + if original: + result["OriginalImage"] = { + "Format": original.get("Format", ""), + "Width": original.get("Width", ""), + "Height": original.get("Height", ""), + } + + # 处理结果 + objects = data.get("ProcessResults", {}).get("Object", []) + if isinstance(objects, dict): + objects = [objects] + result["ProcessedImages"] = [ + { + "Key": obj.get("Key", ""), + "ETag": obj.get("ETag", ""), + "Format": obj.get("Format", ""), + "Width": obj.get("Width", ""), + "Height": obj.get("Height", ""), + "Size": obj.get("Size", ""), + "Quality": obj.get("Quality", ""), + } + for obj in objects + ] + + print_result(result, response) + except Exception as e: + handle_cos_error(e) + + +def image_upload_process(args, parsed_globals): + """上传时处理 + + 通过 PUT 请求调用数据万象 ci_put_object_from_local_file 接口, + 将本地图片上传到 COS,上传的同时进行图片处理并保存结果。 + + Args: + args: 命令参数字典 + - bucket: 存储桶名称 + - local: 本地图片文件路径 + - key: 上传到 COS 的对象键(原图存储路径) + - rule: 数据万象处理规则 + - savekey: (可选) 处理后图片的存储路径,默认与 key 相同 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + local = args["local"] + key = args["key"] + rule = args["rule"] + savekey = args.get("savekey") or key + + # 校验本地文件存在 + if not os.path.isfile(local): + raise FileNotFoundError(f"本地文件不存在: {local}") + + pic_operations = json.dumps({ + "is_pic_info": 1, + "rules": [{"fileid": savekey, "rule": rule}] + }) + + response, data = client.ci_put_object_from_local_file( + Bucket=bucket, + LocalFilePath=local, + Key=key, + PicOperations=pic_operations, + ) + + result = { + "Status": "Success", + "RequestId": response.get("x-cos-request-id", ""), + } + + # 原图信息 + original = data.get("OriginalInfo", {}).get("ImageInfo", {}) + if original: + result["OriginalImage"] = { + "Format": original.get("Format", ""), + "Width": original.get("Width", ""), + "Height": original.get("Height", ""), + } + + # 处理结果 + objects = data.get("ProcessResults", {}).get("Object", []) + if isinstance(objects, dict): + objects = [objects] + result["ProcessedImages"] = [ + { + "Key": obj.get("Key", ""), + "ETag": obj.get("ETag", ""), + "Format": obj.get("Format", ""), + "Width": obj.get("Width", ""), + "Height": obj.get("Height", ""), + "Size": obj.get("Size", ""), + "Quality": obj.get("Quality", ""), + } + for obj in objects + ] + + print_result(result, response) + except Exception as e: + handle_cos_error(e) + + +def image_info(args, parsed_globals): + """获取图片基本信息 + + 调用 SDK 的 ci_get_image_info 方法获取图片的元数据。 + 返回图片格式、尺寸、大小、MD5 等信息。 + + Args: + args: 命令参数字典 + - bucket: 存储桶名称 + - key: 图片在 COS 上的对象键 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + # 使用 SDK 原生的 ci_get_image_info 方法 + response, data = client.ci_get_image_info( + Bucket=args["bucket"], + Key=args["key"], + ) + # SDK 返回的 data 是 rt.content (bytes),需要先解码为 dict + if isinstance(data, bytes): + data = json.loads(data) + print_result(data, response) + except Exception as e: + handle_cos_error(e) + + +# ===================================================================== +# 补充功能:EXIF、主色调、异常图片检测、样式管理 +# ===================================================================== + +def image_exif_info(args, parsed_globals): + """获取图片 EXIF 信息 + + Args: + args: + - bucket: 存储桶名称 + - key: 图片在 COS 上的对象键 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + response = client.ci_get_image_exif_info( + Bucket=args["bucket"], + Key=args["key"], + ) + + resp_headers = None + # 可能返回 bytes + if isinstance(response, (bytes, bytearray)): + response = json.loads(response) + elif isinstance(response, tuple): + resp_headers, data = response + if isinstance(data, bytes): + data = json.loads(data) + response = data + + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def image_ave_color(args, parsed_globals): + """获取图片主色调 + + Args: + args: + - bucket: 存储桶名称 + - key: 图片在 COS 上的对象键 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + response = client.ci_get_image_ave_info( + Bucket=args["bucket"], + Key=args["key"], + ) + + resp_headers = None + # 可能返回 bytes + if isinstance(response, (bytes, bytearray)): + response = json.loads(response) + elif isinstance(response, tuple): + resp_headers, data = response + if isinstance(data, bytes): + data = json.loads(data) + response = data + + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def image_inspect(args, parsed_globals): + """异常图片检测 + + 检测图片中是否隐含其他类型的可疑文件(例如在图片格式中嵌入视频或其他文件)。 + API参考:https://cloud.tencent.com/document/product/460/75997 + + Args: + args: + - bucket: 存储桶名称 + - key: 图片在 COS 上的对象键 + parsed_globals: TCCLI 全局参数 + + Returns: + JSON 结果包含: + - picSize: 检测的原图大小(Bytes) + - picType: 检测的原图类型(如 jpg、png) + - suspicious: 是否检测到图片格式以外的文件(true/false) + - suspiciousBeginByte: 可疑文件起始字节位置 + - suspiciousEndByte: 可疑文件末尾字节位置 + - suspiciousSize: 可疑文件大小 + - suspiciousType: 可疑文件类型(如 MPEG-TS) + """ + try: + client = init_cos_client(parsed_globals) + + response = client.ci_image_inspect( + Bucket=args["bucket"], + Key=args["key"], + ) + + resp_headers = None + if isinstance(response, (bytes, bytearray)): + response = json.loads(response) + elif isinstance(response, tuple): + resp_headers, data = response + if isinstance(data, bytes): + data = json.loads(data) + response = data + + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def image_style_add(args, parsed_globals): + """添加图片样式 + + Args: + args: + - bucket: 存储桶名称 + - style_name: 样式名称 + - style_body: 样式内容(处理规则) + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + response = client.ci_put_image_style( + Bucket=args["bucket"], + Request={ + "StyleName": args["style_name"], + "StyleBody": args["style_body"], + }, + ) + # response 是 headers dict,从中提取 RequestId 并输出 + print_result(response, response) + except Exception as e: + handle_cos_error(e) + + +def image_style_get(args, parsed_globals): + """查询图片样式 + + Args: + args: + - bucket: 存储桶名称 + - style_name: (可选) 样式名称,不指定则列出所有 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + request = {} + if args.get("style_name"): + request["StyleName"] = args["style_name"] + + response = client.ci_get_image_style( + Bucket=args["bucket"], + Request=request, + ) + # ci_get_image_style 返回 (response_headers, data) 元组 + resp_headers = None + if isinstance(response, tuple): + resp_headers, data = response + response = data + print_result(response, resp_headers) + except Exception as e: + handle_cos_error(e) + + +def image_style_delete(args, parsed_globals): + """删除图片样式 + + Args: + args: + - bucket: 存储桶名称 + - style_name: 样式名称 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + response = client.ci_delete_image_style( + Bucket=args["bucket"], + Request={ + "StyleName": args["style_name"], + }, + ) + # response 是 headers dict,从中提取 RequestId 并输出 + print_result(response, response) + except Exception as e: + handle_cos_error(e) diff --git a/tccli/plugins/ci/media_process.py b/tccli/plugins/ci/media_process.py new file mode 100644 index 0000000000..b059e4f3e1 --- /dev/null +++ b/tccli/plugins/ci/media_process.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- +""" +媒体处理模块 + +提供数据万象媒体处理全部功能的 CLI 命令,包括: + +同步处理: +- video_snapshot: 视频同步截帧 +- media_info: 获取媒体文件信息 + +异步任务: +- media_job_submit: 创建媒体处理异步任务(转码、截帧、动图、拼接、智能封面等) +- media_job_query: 查询媒体处理任务 +- media_job_list: 列出媒体处理任务 +- media_job_cancel: 取消媒体处理任务 + +所有异步媒体处理通过统一的 ci_create_media_jobs 接口, +通过 Tag 区分不同类型: + Transcode, Snapshot, Animation, Concat, SmartCover, + VideoProcess, VideoMontage, VoiceSeparate, SDRtoHDR, + DigitalWatermark, ExtractDigitalWatermark, SuperResolution, + VideoTag, Segment, QualityEstimate, SegmentVideoBody, + MediaInfo, SoundHound, NoiseReduction, StreamExtract +""" +import json +import os +import xml.etree.ElementTree as ET + +from .ci_helpers import init_cos_client, handle_cos_error, print_result, save_response_to_file + + +# ===================================================================== +# 同步处理 +# ===================================================================== + +def video_snapshot(args, parsed_globals): + """视频同步截帧 + + 调用 SDK 的 get_snapshot 方法,同步获取视频在指定时间点的截图。 + 返回截图的二进制流,直接写入本地文件。 + + Args: + args: 命令参数字典 + - bucket: 存储桶名称 + - key: 视频在 COS 上的对象键 + - snapshot_time: 截帧时间点(秒,支持小数如 1.5) + - width: (可选) 截图宽度(像素),范围 [0,4096],默认 0 表示原始宽度 + - height: (可选) 截图高度(像素),范围 [0,4096],默认 0 表示原始高度 + - format: (可选) 截图格式,jpg 或 png,默认 jpg + - mode: (可选) 截帧方式,exactframe(精确帧) 或 keyframe(最近关键帧) + - rotate: (可选) 旋转方式,auto(自动旋转) 或 off(不旋转) + - local_path: (可选) 截图保存到本地的路径 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + key = args["key"] + time_point = args["snapshot_time"] + + # 构建可选参数 + kwargs = {} + fmt = args.get("format") or "jpg" + kwargs["Format"] = fmt + + width = args.get("width") + if width is not None and width != "": + kwargs["Width"] = int(width) + + height = args.get("height") + if height is not None and height != "": + kwargs["Height"] = int(height) + + mode = args.get("mode") + if mode is not None and mode != "": + kwargs["Mode"] = mode + + rotate = args.get("rotate") + if rotate is not None and rotate != "": + kwargs["Rotate"] = rotate + + # 使用 SDK 原生的 get_snapshot 方法 + response = client.get_snapshot( + Bucket=bucket, + Key=key, + Time=str(time_point), + **kwargs, + ) + + # 确定输出路径 + default_name = os.path.splitext(os.path.basename(key))[0] + "_snapshot." + fmt + output = args.get("local_path") or default_name + + total_bytes = save_response_to_file(response, output) + + print_result({ + "Status": "Success", + "Output": os.path.abspath(output), + "ContentType": response.get("Content-Type", ""), + "ContentLength": str(total_bytes), + "SnapshotTime": str(time_point), + }, response) + except Exception as e: + handle_cos_error(e) + + +def _xml_to_dict(element): + """递归将 XML Element 转换为 dict + + 处理规则: + - 叶子节点:tag -> text + - 非叶子节点:tag -> {children dict} + - 同名多节点:tag -> [values list] + + Args: + element: xml.etree.ElementTree.Element + + Returns: + dict: XML 结构的字典表示 + """ + result = {} + for child in element: + # 去除命名空间前缀(如有) + tag = child.tag + if "}" in tag: + tag = tag.split("}", 1)[1] + + if len(child) > 0: + value = _xml_to_dict(child) + else: + value = child.text or "" + + # 处理同名多节点 + if tag in result: + if not isinstance(result[tag], list): + result[tag] = [result[tag]] + result[tag].append(value) + else: + result[tag] = value + + return result + + +def media_info(args, parsed_globals): + """获取媒体文件信息 + + 调用 SDK 的 get_media_info 方法获取媒体文件的详细信息。 + SDK 直接返回解析好的 dict 结构。 + + 输出内容包括: + - MediaInfo.Format: 容器格式(FormatName、Duration、Bitrate、Size 等) + - MediaInfo.Stream.Video: 视频流信息(CodecName、Width、Height、Fps 等) + - MediaInfo.Stream.Audio: 音频流信息(CodecName、SampleRate、Channel 等) + - MediaInfo.Stream.Subtitle: 字幕流信息(如有) + + Args: + args: 命令参数字典 + - bucket: 存储桶名称 + - key: 媒体文件在 COS 上的对象键 + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + # 使用 SDK 原生的 get_media_info 方法 + response = client.get_media_info( + Bucket=args["bucket"], + Key=args["key"], + ) + + print_result(response) + except Exception as e: + handle_cos_error(e) + + +# ===================================================================== +# 异步任务 +# ===================================================================== + +def media_job_submit(args, parsed_globals): + """创建媒体处理异步任务 + + 通过 Tag 区分不同的处理类型(转码、截帧、动图等)。 + 操作参数通过 JSON 字符串传入。 + + Args: + args: + - bucket: 存储桶名称 + - key: 输入文件的 COS 对象键 + - tag: 任务类型标签,如 Transcode/Snapshot/Animation 等 + - operation: 操作参数 JSON 字符串 + - output_bucket: (可选) 输出存储桶 + - output_key: (可选) 输出文件的 COS 对象键 + - output_region: (可选) 输出区域 + - queue_id: (可选) 队列 ID + - callback: (可选) 回调 URL + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + bucket = args["bucket"] + tag = args["tag"] + + # 构建操作参数 + operation = {} + if args.get("operation"): + operation = json.loads(args["operation"]) + + # 如果指定了 output_key,构建 Output 节点 + if args.get("output_key"): + operation["Output"] = { + "Bucket": args.get("output_bucket") or bucket, + "Region": args.get("output_region") or parsed_globals.get("region") or "ap-guangzhou", + "Object": args["output_key"], + } + + kwargs = { + "Tag": tag, + "Input": {"Object": args["key"]}, + "Operation": operation, + } + + if args.get("queue_id"): + kwargs["QueueId"] = args["queue_id"] + if args.get("callback"): + kwargs["CallBack"] = args["callback"] + + response = client.ci_create_media_jobs( + Bucket=bucket, + Jobs=kwargs, + ContentType='application/xml', + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def media_job_query(args, parsed_globals): + """查询媒体处理任务 + + Args: + args: + - bucket: 存储桶名称 + - job_id: 任务 ID + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + response = client.ci_get_media_jobs( + Bucket=args["bucket"], + JobIDs=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def media_job_list(args, parsed_globals): + """列出媒体处理任务 + + Args: + args: + - bucket: 存储桶名称 + - tag: 任务类型标签 + - queue_id: (可选) 队列 ID + - status: (可选) 任务状态 All/Submitted/Running/Success/Failed/Pause/Cancel + - size: (可选) 每页数量,默认 10 + - next_token: (可选) 翻页标记 + - order_by_time: (可选) 排序方式 Desc/Asc + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + kwargs = { + "Bucket": args["bucket"], + "Tag": args["tag"], + } + + if args.get("queue_id"): + kwargs["QueueId"] = args["queue_id"] + if args.get("status"): + kwargs["States"] = args["status"] + if args.get("size"): + kwargs["Size"] = int(args["size"]) + if args.get("next_token"): + kwargs["NextToken"] = args["next_token"] + if args.get("order_by_time"): + kwargs["OrderByTime"] = args["order_by_time"] + + response = client.ci_list_media_jobs(**kwargs) + print_result(response) + except Exception as e: + handle_cos_error(e) + + +def media_job_cancel(args, parsed_globals): + """取消媒体处理任务 + + Args: + args: + - bucket: 存储桶名称 + - job_id: 任务 ID + parsed_globals: TCCLI 全局参数 + """ + try: + client = init_cos_client(parsed_globals) + + response = client.ci_cancel_jobs( + Bucket=args["bucket"], + JobID=args["job_id"], + ) + print_result(response) + except Exception as e: + handle_cos_error(e) diff --git a/tccli/plugins/ci/tests/__init__.py b/tccli/plugins/ci/tests/__init__.py new file mode 100644 index 0000000000..85f3bbab25 --- /dev/null +++ b/tccli/plugins/ci/tests/__init__.py @@ -0,0 +1 @@ +# Tests for CI plugin diff --git a/tccli/plugins/ci/tests/conftest.py b/tccli/plugins/ci/tests/conftest.py new file mode 100644 index 0000000000..ecbe188c04 --- /dev/null +++ b/tccli/plugins/ci/tests/conftest.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +""" +conftest.py — 自动将项目根目录加入 sys.path, +使 `from tccli.plugins.ci.xxx import ...` 在任何位置运行 pytest 时都能正确解析。 +""" + +import os +import sys + +# tccli/plugins/ci/tests/ -> 上溯 4 级到包含 tccli/ 的根目录 +_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) diff --git a/tccli/plugins/ci/tests/test_ai_recognition.py b/tccli/plugins/ci/tests/test_ai_recognition.py new file mode 100644 index 0000000000..ddccad6f98 --- /dev/null +++ b/tccli/plugins/ci/tests/test_ai_recognition.py @@ -0,0 +1,909 @@ +# -*- coding: utf-8 -*- +"""ai_recognition 模块单元测试 + +覆盖目标: +- _ci_process_stream: dict 响应(错误) / stream+local_path / stream 无 local_path +- _ci_process_json: 正常 +- 20 个 AI 公开函数:key 缺失 / 成功 / 异常 / 可选参数分支 +""" +import json +import os +import unittest +from unittest.mock import patch, MagicMock + +from tccli.plugins.ci.ai_recognition import ( + _ci_process_stream, + _ci_process_json, + ai_image_coloring, + ai_enhance_image, + ai_image_crop, + ai_image_repair, + ai_detect_face, + ai_face_effect, + ai_body_recognition, + ai_id_card_ocr, + ai_license_rec, + ai_game_rec, + ocr_process, + detect_label, + detect_car, + assess_quality, + qrcode_detect, + qrcode_generate, + super_resolution, + goods_matting, + pic_matting, + portrait_matting, +) + +MOCK_INIT = "tccli.plugins.ci.ai_recognition.init_cos_client" +MOCK_PRINT = "tccli.plugins.ci.ai_recognition.print_result" +MOCK_HANDLE = "tccli.plugins.ci.ai_recognition.handle_cos_error" +MOCK_SAVE = "tccli.plugins.ci.ai_recognition.save_response_to_file" + +PG = {"secretId": "AK", "secretKey": "SK", "region": "ap-guangzhou"} + + +class TestCiProcessStream(unittest.TestCase): + """_ci_process_stream 私有方法测试""" + + def test_dict_response(self): + """ci_process 返回 dict (错误响应)""" + client = MagicMock() + client.ci_process.return_value = ({"h": "v"}, {"Error": "bad"}) + + result, headers = _ci_process_stream(client, "b", "k", "AIImageColoring") + self.assertEqual(result, {"Error": "bad"}) + + @patch(MOCK_SAVE, return_value=1024) + def test_stream_with_local_path(self, mock_save): + """stream 响应 + 指定 local_path""" + client = MagicMock() + stream_body = MagicMock() + stream_body.__class__ = type("StreamLike", (), {}) + client.ci_process.return_value = ({"h": "v"}, stream_body) + + result, headers = _ci_process_stream(client, "b", "k", "AIImageColoring", + local_path="/tmp/out.jpg") + self.assertEqual(result["Status"], "Success") + self.assertEqual(result["ContentLength"], "1024") + + def test_stream_without_local_path(self): + """stream 响应 + 无 local_path""" + client = MagicMock() + stream_body = MagicMock() + stream_body.__class__ = type("StreamLike", (), {}) + client.ci_process.return_value = ({"h": "v"}, stream_body) + + result, headers = _ci_process_stream(client, "b", "k", "AIImageColoring") + self.assertEqual(result["Status"], "Success") + self.assertIn("no local_path", result["Message"]) + + +class TestCiProcessJson(unittest.TestCase): + """_ci_process_json 私有方法测试""" + + def test_normal(self): + client = MagicMock() + client.ci_process.return_value = ({"h": "v"}, {"data": "ok"}) + result, headers = _ci_process_json(client, "b", "k", "DetectFace") + self.assertEqual(result, {"data": "ok"}) + self.assertEqual(headers, {"h": "v"}) + + def test_with_params(self): + client = MagicMock() + client.ci_process.return_value = ({}, {"result": 1}) + result, _ = _ci_process_json(client, "b", "k", "OCR", {"lang": "zh"}) + self.assertEqual(result, {"result": 1}) + client.ci_process.assert_called_once_with( + Bucket="b", Key="k", CiProcess="OCR", Params={"lang": "zh"}, NeedHeader=True) + + +# --------------------------------------------------------------------------- +# 辅助:构造通用的 stream 测试 + json 测试 +# --------------------------------------------------------------------------- + +def _make_stream_test(func_name, func, required_args, optional_args=None): + """为返回 stream 的 AI 函数生成测试方法""" + + class StreamTests(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({"h": "v"}, {"Error": "bad"}) + mock_init.return_value = client + args = {**required_args} + func(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + func({**required_args}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + args = {k: v for k, v in required_args.items() if k != "key"} + func(args, PG) + mock_handle.assert_called_once() + err = mock_handle.call_args[0][0] + self.assertIsInstance(err, ValueError) + + StreamTests.__name__ = f"Test_{func_name}" + StreamTests.__qualname__ = f"Test_{func_name}" + return StreamTests + + +def _make_json_test(func_name, func, required_args): + """为返回 JSON 的 AI 函数生成测试方法""" + + class JsonTests(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({"h": "v"}, {"result": "ok"}) + mock_init.return_value = client + func({**required_args}, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + func({**required_args}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + args = {k: v for k, v in required_args.items() if k != "key"} + func(args, PG) + mock_handle.assert_called_once() + + JsonTests.__name__ = f"Test_{func_name}" + JsonTests.__qualname__ = f"Test_{func_name}" + return JsonTests + + +# --------------------------------------------------------------------------- +# 具体测试类 +# --------------------------------------------------------------------------- + +class TestAiImageColoring(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "url": "http://example.com/img.jpg"} + ai_image_coloring(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertIn("detect-url", params) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success_with_local_path(self, mock_init, mock_print): + client = MagicMock() + stream = MagicMock() + stream.__class__ = type("S", (), {}) + client.ci_process.return_value = ({}, stream) + mock_init.return_value = client + with patch(MOCK_SAVE, return_value=100): + args = {"bucket": "b", "key": "k", "local_path": "/tmp/out.jpg"} + ai_image_coloring(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_image_coloring({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_image_coloring({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestAiEnhanceImage(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all_params(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "denoise": "3", "sharpen": "5", "url": "http://x.com/i.jpg"} + ai_enhance_image(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["denoise"], "3") + self.assertEqual(params["sharpen"], "5") + self.assertIn("detect-url", params) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_enhance_image({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAiImageCrop(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_fixed(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "width": "100", "height": "100", "fixed": "1", "url": "http://x.com"} + ai_image_crop(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["fixed"], "1") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_image_crop({"bucket": "b", "width": "100", "height": "100"}, PG) + mock_handle.assert_called_once() + + +class TestAiImageRepair(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_mask_key(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "mask_key": "mask.jpg"} + ai_image_repair(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["MaskPic"], "mask.jpg") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_mask_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "mask_url": "http://mask.com/m.jpg"} + ai_image_repair(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["MaskPic"], "http://mask.com/m.jpg") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_no_mask(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k"} + ai_image_repair(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertNotIn("MaskPic", params) + + +class TestAiDetectFace(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_max_face_num(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"FaceCount": 2}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "max_face_num": "5"} + ai_detect_face(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["max-face-num"], "5") + + +class TestAiFaceEffect(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "effect_type": "aging", "url": "http://x.com"} + ai_face_effect(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["type"], "aging") + self.assertIn("detect-url", params) + + +class TestAiBodyRecognition(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Bodies": []}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "url": "http://x.com"} + ai_body_recognition(args, PG) + + +class TestAiIdCardOcr(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_card_side(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"IdInfo": {}}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "card_side": "FRONT", "url": "http://x.com"} + ai_id_card_ocr(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["CardSide"], "FRONT") + + +class TestAiLicenseRec(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_card_type(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"License": {}}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "card_type": "motor", "url": "http://x.com"} + ai_license_rec(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["CardType"], "motor") + + +class TestAiGameRec(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"GameInfo": {}}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k"} + ai_game_rec(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "url": "http://x.com"} + ai_game_rec(args, PG) + + +class TestOcrProcess(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_key(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"TextDetections": []}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k"} + ocr_process(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url_no_key(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {}) + mock_init.return_value = client + args = {"bucket": "b", "url": "http://x.com/doc.jpg"} + ocr_process(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all_params(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "language_type": "zh-CN", + "ispdf": "true", "pdf_pagenumber": "1"} + ocr_process(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["language-type"], "zh-CN") + self.assertEqual(params["ispdf"], "true") + self.assertEqual(params["pdf-pagenumber"], "1") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_no_key_no_url(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + args = {"bucket": "b"} + ocr_process(args, PG) + mock_handle.assert_called_once() + + +class TestDetectLabel(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_key(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Labels": []}) + mock_init.return_value = client + detect_label({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {}) + mock_init.return_value = client + detect_label({"bucket": "b", "url": "http://x.com"}, PG) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_no_key_no_url(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + detect_label({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestDetectCar(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Cars": []}) + mock_init.return_value = client + detect_car({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_no_key_no_url(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + detect_car({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAssessQuality(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Score": 90}) + mock_init.return_value = client + assess_quality({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_no_key_no_url(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + assess_quality({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestQrcodeDetect(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_cover(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"CodeStatus": 1}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "cover": "1"} + qrcode_detect(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["cover"], "1") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_no_cover(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {}) + mock_init.return_value = client + args = {"bucket": "b", "key": "k"} + qrcode_detect(args, PG) + params = client.ci_process.call_args[1]["Params"] + self.assertEqual(params["cover"], "0") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_no_key_no_url(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + qrcode_detect({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestQrcodeGenerate(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_minimal(self, mock_init, mock_print): + client = MagicMock() + client.ci_qrcode_generate.return_value = {"ResultImage": ""} + mock_init.return_value = client + args = {"bucket": "b", "text": "hello"} + qrcode_generate(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch("builtins.open", new_callable=unittest.mock.mock_open) + @patch("os.makedirs") + @patch(MOCK_INIT) + def test_with_local_path(self, mock_init, mock_makedirs, mock_open_fn, mock_print): + """保存二维码图片""" + import base64 + img_b64 = base64.b64encode(b"fakepng").decode() + client = MagicMock() + client.ci_qrcode_generate.return_value = {"ResultImage": img_b64} + mock_init.return_value = client + args = {"bucket": "b", "text": "hello", "local_path": "/tmp/qr.png"} + qrcode_generate(args, PG) + result = mock_print.call_args[0][0] + self.assertIn("Output", result) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_width_mode(self, mock_init, mock_print): + client = MagicMock() + client.ci_qrcode_generate.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "text": "t", "width": "200", "mode": "1"} + qrcode_generate(args, PG) + call_kwargs = client.ci_qrcode_generate.call_args[1] + self.assertEqual(call_kwargs["Width"], 200) + self.assertEqual(call_kwargs["Mode"], 1) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + qrcode_generate({"bucket": "b", "text": "t"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_local_path_no_result_image(self, mock_init, mock_print): + """local_path 有但 ResultImage 为空""" + client = MagicMock() + client.ci_qrcode_generate.return_value = {"ResultImage": ""} + mock_init.return_value = client + args = {"bucket": "b", "text": "t", "local_path": "/tmp/qr.png"} + qrcode_generate(args, PG) + # ResultImage 为空,不保存文件 + result = mock_print.call_args[0][0] + self.assertNotIn("Output", result) + + +class TestSuperResolution(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + stream = MagicMock() + stream.__class__ = type("S", (), {}) + client.ci_process.return_value = ({}, stream) + mock_init.return_value = client + with patch(MOCK_SAVE, return_value=2048): + args = {"bucket": "b", "key": "k", "local_path": "/tmp/out.jpg"} + super_resolution(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + super_resolution({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestGoodsMatting(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + goods_matting({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + goods_matting({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + goods_matting({"bucket": "b", "key": "k", "url": "http://x.com"}, PG) + + +class TestPicMatting(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + pic_matting({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + pic_matting({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestPortraitMatting(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + portrait_matting({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + portrait_matting({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + portrait_matting({"bucket": "b", "key": "k", "url": "http://x.com"}, PG) + + +# --------------------------------------------------------------------------- +# 补充:覆盖各函数 error 分支 +# --------------------------------------------------------------------------- + +class TestAiEnhanceImageError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_enhance_image({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestAiImageCropError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_image_crop({"bucket": "b", "key": "k", "width": "100", "height": "100"}, PG) + mock_handle.assert_called_once() + + +class TestAiImageRepairError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_image_repair({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_image_repair({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAiDetectFaceError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_detect_face({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_detect_face({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAiFaceEffectError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_face_effect({"bucket": "b", "key": "k", "effect_type": "aging"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_face_effect({"bucket": "b", "effect_type": "aging"}, PG) + mock_handle.assert_called_once() + + +class TestAiBodyRecognitionError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_body_recognition({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_body_recognition({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAiIdCardOcrError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_id_card_ocr({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_id_card_ocr({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAiLicenseRecError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_license_rec({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_license_rec({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAiGameRecError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ai_game_rec({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_missing_key(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + ai_game_rec({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestOcrProcessError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + ocr_process({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestDetectLabelError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + detect_label({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestDetectCarError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + detect_car({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {}) + mock_init.return_value = client + detect_car({"bucket": "b", "url": "http://x.com"}, PG) + + +class TestAssessQualityError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + assess_quality({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {}) + mock_init.return_value = client + assess_quality({"bucket": "b", "url": "http://x.com"}, PG) + + +class TestQrcodeDetectError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + qrcode_detect({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {}) + mock_init.return_value = client + qrcode_detect({"bucket": "b", "url": "http://x.com"}, PG) + + +class TestGoodsMattingError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + goods_matting({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestPicMattingError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + pic_matting({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_process.return_value = ({}, {"Error": "test"}) + mock_init.return_value = client + pic_matting({"bucket": "b", "key": "k", "url": "http://x.com"}, PG) + + +class TestPortraitMattingError(unittest.TestCase): + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + portrait_matting({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tccli/plugins/ci/tests/test_auditing.py b/tccli/plugins/ci/tests/test_auditing.py new file mode 100644 index 0000000000..544a247fd2 --- /dev/null +++ b/tccli/plugins/ci/tests/test_auditing.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- +"""auditing 模块单元测试""" +import unittest +from unittest.mock import patch, MagicMock + +from tccli.plugins.ci.auditing import ( + _parse_detect_type, + auditing_image, + auditing_video_submit, auditing_video_query, + auditing_audio_submit, auditing_audio_query, + auditing_text_submit, auditing_text_query, + auditing_document_submit, auditing_document_query, + auditing_webpage_submit, auditing_webpage_query, + auditing_live_submit, auditing_live_cancel, + auditing_virus_submit, auditing_virus_query, + auditing_report_badcase, +) + +MOCK_INIT = "tccli.plugins.ci.auditing.init_cos_client" +MOCK_PRINT = "tccli.plugins.ci.auditing.print_result" +MOCK_HANDLE = "tccli.plugins.ci.auditing.handle_cos_error" +PG = {"secretId": "AK", "secretKey": "SK", "region": "ap-guangzhou"} + + +class TestParseDetectType(unittest.TestCase): + def test_none(self): + self.assertIsNone(_parse_detect_type(None)) + + def test_empty(self): + self.assertIsNone(_parse_detect_type("")) + + def test_numeric(self): + self.assertEqual(_parse_detect_type("15"), 15) + + def test_single_name(self): + self.assertEqual(_parse_detect_type("porn"), 1) + + def test_multiple_comma(self): + self.assertEqual(_parse_detect_type("porn,terrorist"), 3) + + def test_multiple_pipe(self): + self.assertEqual(_parse_detect_type("porn|ads"), 9) + + def test_all_types(self): + self.assertEqual(_parse_detect_type("porn,terrorist,politics,ads"), 15) + + def test_terrorism_alias(self): + self.assertEqual(_parse_detect_type("terrorism"), 2) + + def test_unknown_returns_none(self): + self.assertIsNone(_parse_detect_type("unknown")) + + def test_mixed_case(self): + self.assertEqual(_parse_detect_type("Porn,ADS"), 9) + + +class TestAuditingImage(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_keys(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_image_batch.return_value = {"JobsDetail": []} + mock_init.return_value = client + args = {"bucket": "b", "key": "a.jpg,b.jpg", "detect_type": "porn,ads", "biz_type": "biz1"} + auditing_image(args, PG) + kw = client.ci_auditing_image_batch.call_args[1] + self.assertEqual(len(kw["Input"]), 2) + self.assertEqual(kw["DetectType"], 9) + self.assertEqual(kw["BizType"], "biz1") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_urls(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_image_batch.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "url": "http://a.com, http://b.com"} + auditing_image(args, PG) + kw = client.ci_auditing_image_batch.call_args[1] + self.assertEqual(len(kw["Input"]), 2) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_no_input(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + auditing_image({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + auditing_image({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingVideoSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all_params(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_video_submit.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "v.mp4", "url": "http://x.com", "detect_type": "porn", + "biz_type": "biz", "snapshot_mode": "Interval", "snapshot_count": "10"} + auditing_video_submit(args, PG) + kw = client.ci_auditing_video_submit.call_args[1] + self.assertEqual(kw["DetectType"], 1) + self.assertEqual(kw["Count"], 10) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_minimal(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_video_submit.return_value = {} + mock_init.return_value = client + auditing_video_submit({"bucket": "b"}, PG) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + auditing_video_submit({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingVideoQuery(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_video_query.return_value = {} + mock_init.return_value = client + auditing_video_query({"bucket": "b", "job_id": "j1"}, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + auditing_video_query({"bucket": "b", "job_id": "j1"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingAudioSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all_params(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_audio_submit.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "a.mp3", "url": "http://x.com", + "detect_type": "15", "biz_type": "b1"} + auditing_audio_submit(args, PG) + kw = client.ci_auditing_audio_submit.call_args[1] + self.assertEqual(kw["DetectType"], 15) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + auditing_audio_submit({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingAudioQuery(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_audio_query.return_value = {} + mock_init.return_value = client + auditing_audio_query({"bucket": "b", "job_id": "j1"}, PG) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + auditing_audio_query({"bucket": "b", "job_id": "j1"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingTextSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_key(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_text_submit.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "text.txt", "detect_type": "porn"} + auditing_text_submit(args, PG) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_content_str(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_text_submit.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "content": "测试文本", "biz_type": "biz1"} + auditing_text_submit(args, PG) + kw = client.ci_auditing_text_submit.call_args[1] + self.assertIsInstance(kw["Content"], bytes) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_content_bytes(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_text_submit.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "content": b"bytes text"} + auditing_text_submit(args, PG) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_text_submit.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "url": "http://x.com/t.txt"} + auditing_text_submit(args, PG) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_no_input(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + auditing_text_submit({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingTextQuery(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_text_query.return_value = {} + mock_init.return_value = client + auditing_text_query({"bucket": "b", "job_id": "j"}, PG) + + +class TestAuditingDocumentSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_document_submit.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "doc.pdf", "url": "http://x.com", + "doc_type": "pdf", "detect_type": "porn", "biz_type": "b1"} + auditing_document_submit(args, PG) + kw = client.ci_auditing_document_submit.call_args[1] + self.assertEqual(kw["Type"], "pdf") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_no_input(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + auditing_document_submit({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingDocumentQuery(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_document_query.return_value = {} + mock_init.return_value = client + auditing_document_query({"bucket": "b", "job_id": "j"}, PG) + + +class TestAuditingWebpageSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_html_submit.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "url": "http://x.com", "detect_type": "porn", "biz_type": "b1"} + auditing_webpage_submit(args, PG) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + auditing_webpage_submit({"bucket": "b", "url": "u"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingWebpageQuery(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_html_query.return_value = {} + mock_init.return_value = client + auditing_webpage_query({"bucket": "b", "job_id": "j"}, PG) + + +class TestAuditingLiveSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_live_video_submit.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "url": "rtmp://x.com", "biz_type": "b", "callback": "http://cb"} + auditing_live_submit(args, PG) + kw = client.ci_auditing_live_video_submit.call_args[1] + self.assertEqual(kw["Callback"], "http://cb") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + auditing_live_submit({"bucket": "b", "url": "u"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingLiveCancel(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_live_video_cancle.return_value = {} + mock_init.return_value = client + auditing_live_cancel({"bucket": "b", "job_id": "j"}, PG) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + auditing_live_cancel({"bucket": "b", "job_id": "j"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingVirusSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_key(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_virus_submit.return_value = {} + mock_init.return_value = client + auditing_virus_submit({"bucket": "b", "key": "f.exe"}, PG) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_url(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_virus_submit.return_value = {} + mock_init.return_value = client + auditing_virus_submit({"bucket": "b", "url": "http://x.com/f"}, PG) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_no_input(self, mock_init, mock_handle): + mock_init.return_value = MagicMock() + auditing_virus_submit({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestAuditingVirusQuery(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_virus_query.return_value = {} + mock_init.return_value = client + auditing_virus_query({"bucket": "b", "job_id": "j"}, PG) + + +class TestAuditingReportBadcase(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_report_badcase.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "content_type": "1", "label": "Porn", + "suggestion_label": "Normal", "url": "http://x.com", "text": "test"} + auditing_report_badcase(args, PG) + kw = client.ci_auditing_report_badcase.call_args[1] + self.assertEqual(kw["ContentType"], 1) + self.assertEqual(kw["SuggestedLabel"], "Normal") + self.assertEqual(kw["Url"], "http://x.com") + self.assertEqual(kw["Text"], "test") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_default_suggestion(self, mock_init, mock_print): + client = MagicMock() + client.ci_auditing_report_badcase.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "content_type": "2", "label": "Ads"} + auditing_report_badcase(args, PG) + kw = client.ci_auditing_report_badcase.call_args[1] + self.assertEqual(kw["SuggestedLabel"], "Block") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + auditing_report_badcase({"bucket": "b", "content_type": "1", "label": "L"}, PG) + mock_handle.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tccli/plugins/ci/tests/test_ci_helpers.py b/tccli/plugins/ci/tests/test_ci_helpers.py new file mode 100644 index 0000000000..fc39618835 --- /dev/null +++ b/tccli/plugins/ci/tests/test_ci_helpers.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +"""ci_helpers 模块单元测试 + +覆盖目标: +- _load_credential_from_profile: 命令行传入 / 文件加载 / 环境变量 / 缺失报错 +- init_cos_client: 正常初始化 + 密钥缺失 +- handle_cos_error: CosServiceError / 通用 Exception +- _extract_request_id: 正常 / 空头 / None / 非 dict +- print_result: 带 headers / 不带 headers / data 非 dict +- save_response_to_file: StreamBody / dict+Body(StreamBody) / dict+Body(raw) / bytes / 不支持类型 +""" +import json +import os +import sys +import unittest +from unittest.mock import patch, MagicMock, mock_open + +from tccli.plugins.ci.ci_helpers import ( + _load_credential_from_profile, + init_cos_client, + handle_cos_error, + _extract_request_id, + print_result, + save_response_to_file, +) + + +class TestLoadCredentialFromProfile(unittest.TestCase): + """测试 _load_credential_from_profile""" + + def test_already_has_credentials(self): + """命令行已传入 secretId 和 secretKey,直接返回""" + pg = {"secretId": "AKIDxxx", "secretKey": "SKxxx"} + result = _load_credential_from_profile(pg) + self.assertEqual(result["secretId"], "AKIDxxx") + self.assertEqual(result["secretKey"], "SKxxx") + + @patch("os.path.isfile", return_value=True) + @patch("builtins.open", mock_open(read_data='{"secretId":"file_id","secretKey":"file_key","token":"file_token"}')) + def test_load_from_credential_file(self, mock_isfile): + """从 credential 文件加载密钥""" + pg = {"secretId": None, "secretKey": None, "profile": "default"} + result = _load_credential_from_profile(pg) + self.assertEqual(result["secretId"], "file_id") + self.assertEqual(result["secretKey"], "file_key") + self.assertEqual(result["token"], "file_token") + + @patch("os.path.isfile", return_value=False) + @patch.dict(os.environ, { + "TENCENTCLOUD_SECRET_ID": "env_id", + "TENCENTCLOUD_SECRET_KEY": "env_key", + "TENCENTCLOUD_TOKEN": "env_token", + }) + def test_load_from_env_vars(self, mock_isfile): + """从环境变量加载密钥""" + pg = {"secretId": None, "secretKey": None, "profile": None, "token": None} + result = _load_credential_from_profile(pg) + self.assertEqual(result["secretId"], "env_id") + self.assertEqual(result["secretKey"], "env_key") + self.assertEqual(result["token"], "env_token") + + @patch("os.path.isfile", return_value=False) + @patch.dict(os.environ, {}, clear=True) + def test_missing_credentials_raises(self, mock_isfile): + """密钥缺失时抛出 ValueError""" + pg = {"secretId": None, "secretKey": None, "profile": "default", "token": None} + with self.assertRaises(ValueError) as ctx: + _load_credential_from_profile(pg) + self.assertIn("SecretId and SecretKey is Required", str(ctx.exception)) + + @patch("os.path.isfile") + @patch("builtins.open", mock_open(read_data='invalid json')) + def test_invalid_json_in_credential_file(self, mock_isfile): + """credential 文件 JSON 解析失败,降级到环境变量""" + mock_isfile.return_value = True + + with patch.dict(os.environ, { + "TENCENTCLOUD_SECRET_ID": "env_id", + "TENCENTCLOUD_SECRET_KEY": "env_key", + }): + pg = {"secretId": None, "secretKey": None, "profile": "default", "token": None} + result = _load_credential_from_profile(pg) + self.assertEqual(result["secretId"], "env_id") + + @patch("os.path.isfile") + @patch("builtins.open") + def test_load_region_from_configure_file(self, mock_open_func, mock_isfile): + """从 configure 文件加载 region""" + cred_data = '{"secretId":"id","secretKey":"key"}' + conf_data = '{"_sys_param":{"region":"ap-beijing"}}' + + def isfile_side_effect(path): + return True + + mock_isfile.side_effect = isfile_side_effect + + # 第一次 open 返回 cred, 第二次返回 conf + mock_open_func.side_effect = [ + mock_open(read_data=cred_data)(), + mock_open(read_data=conf_data)(), + ] + + pg = {"secretId": None, "secretKey": None, "profile": "default", "token": None, "region": None} + result = _load_credential_from_profile(pg) + self.assertEqual(result["region"], "ap-beijing") + + @patch("os.path.isfile") + @patch("builtins.open") + def test_configure_file_invalid_json(self, mock_open_func, mock_isfile): + """configure 文件 JSON 无效时跳过 region 加载""" + cred_data = '{"secretId":"id","secretKey":"key"}' + conf_data = 'not json' + + mock_isfile.return_value = True + mock_open_func.side_effect = [ + mock_open(read_data=cred_data)(), + mock_open(read_data=conf_data)(), + ] + + pg = {"secretId": None, "secretKey": None, "profile": "default", "token": None, "region": None} + result = _load_credential_from_profile(pg) + # region 未被设置 + self.assertIsNone(result.get("region")) + + @patch("os.path.isfile", return_value=False) + @patch.dict(os.environ, { + "TENCENTCLOUD_SECRET_ID": "env_id", + "TENCENTCLOUD_SECRET_KEY": "env_key", + "TENCENTCLOUD_REGION": "ap-shanghai", + "TCCLI_PROFILE": "myprofile", + }) + def test_env_region_and_profile(self, mock_isfile): + """使用环境变量的 TCCLI_PROFILE 和 TENCENTCLOUD_REGION""" + pg = {"secretId": None, "secretKey": None, "profile": None, "token": None, "region": None} + # configure 文件不存在,走 env fallback + result = _load_credential_from_profile(pg) + self.assertEqual(result["secretId"], "env_id") + + def test_partial_credentials_id_only(self): + """只有 secretId,缺少 secretKey""" + with patch("os.path.isfile", return_value=False), \ + patch.dict(os.environ, {}, clear=True): + pg = {"secretId": "AKIDxxx", "secretKey": None, "profile": "default", "token": None} + with self.assertRaises(ValueError): + _load_credential_from_profile(pg) + + +class TestInitCosClient(unittest.TestCase): + """测试 init_cos_client""" + + @patch("tccli.plugins.ci.ci_helpers.CosS3Client") + @patch("tccli.plugins.ci.ci_helpers.CosConfig") + @patch("tccli.plugins.ci.ci_helpers._load_credential_from_profile") + def test_normal_init(self, mock_load, mock_config, mock_client_cls): + """正常初始化 COS 客户端""" + mock_load.return_value = None + pg = { + "secretId": "AKIDxxx", + "secretKey": "SKxxx", + "region": "ap-guangzhou", + "token": None, + "endpoint": None, + } + client = init_cos_client(pg) + mock_config.assert_called_once_with( + Region="ap-guangzhou", + SecretId="AKIDxxx", + SecretKey="SKxxx", + Token=None, + Endpoint=None, + ) + mock_client_cls.assert_called_once() + + @patch("tccli.plugins.ci.ci_helpers.CosS3Client") + @patch("tccli.plugins.ci.ci_helpers.CosConfig") + @patch("tccli.plugins.ci.ci_helpers._load_credential_from_profile") + def test_default_region(self, mock_load, mock_config, mock_client_cls): + """region 为空时使用默认值 ap-guangzhou""" + mock_load.return_value = None + pg = {"secretId": "AKIDxxx", "secretKey": "SKxxx", "region": None, "token": None, "endpoint": None} + init_cos_client(pg) + mock_config.assert_called_once_with( + Region="ap-guangzhou", + SecretId="AKIDxxx", + SecretKey="SKxxx", + Token=None, + Endpoint=None, + ) + + +class TestHandleCosError(unittest.TestCase): + """测试 handle_cos_error""" + + @patch("sys.exit") + @patch("sys.stderr") + def test_cos_service_error(self, mock_stderr, mock_exit): + """CosServiceError 格式化输出""" + from qcloud_cos import CosServiceError + + err = MagicMock(spec=CosServiceError) + err.get_error_code.return_value = "NoSuchKey" + err.get_error_msg.return_value = "The specified key does not exist" + err.get_request_id.return_value = "req-123" + err.get_status_code.return_value = 404 + # 让 isinstance 检查通过 + err.__class__ = CosServiceError + + handle_cos_error(err) + mock_exit.assert_called_once_with(1) + + @patch("sys.exit") + @patch("sys.stderr") + def test_generic_error(self, mock_stderr, mock_exit): + """通用异常格式化输出""" + err = ValueError("test error") + handle_cos_error(err) + mock_exit.assert_called_once_with(1) + + +class TestExtractRequestId(unittest.TestCase): + """测试 _extract_request_id""" + + def test_both_ids(self): + """包含 ci 和 cos 两个 request id""" + headers = {"x-ci-request-id": "ci-123", "x-cos-request-id": "cos-456"} + result = _extract_request_id(headers) + self.assertEqual(result["RequestId"], "ci-123") + self.assertEqual(result["CosRequestId"], "cos-456") + + def test_only_ci_id(self): + """只有 ci request id""" + headers = {"x-ci-request-id": "ci-123"} + result = _extract_request_id(headers) + self.assertEqual(result["RequestId"], "ci-123") + self.assertNotIn("CosRequestId", result) + + def test_only_cos_id(self): + """只有 cos request id""" + headers = {"x-cos-request-id": "cos-456"} + result = _extract_request_id(headers) + self.assertNotIn("RequestId", result) + self.assertEqual(result["CosRequestId"], "cos-456") + + def test_no_ids(self): + """header 中没有 request id""" + headers = {"Content-Type": "application/json"} + result = _extract_request_id(headers) + self.assertEqual(result, {}) + + def test_none_headers(self): + """headers 为 None""" + result = _extract_request_id(None) + self.assertEqual(result, {}) + + def test_empty_headers(self): + """headers 为空 dict""" + result = _extract_request_id({}) + self.assertEqual(result, {}) + + def test_non_dict_headers(self): + """headers 不是 dict""" + result = _extract_request_id("not a dict") + self.assertEqual(result, {}) + + +class TestPrintResult(unittest.TestCase): + """测试 print_result""" + + @patch("builtins.print") + def test_print_dict_with_headers(self, mock_print): + """dict 输出时注入 RequestId""" + data = {"key": "value"} + headers = {"x-ci-request-id": "ci-123"} + print_result(data, headers) + output = mock_print.call_args[0][0] + parsed = json.loads(output) + self.assertEqual(parsed["RequestId"], "ci-123") + self.assertEqual(parsed["key"], "value") + + @patch("builtins.print") + def test_print_dict_without_headers(self, mock_print): + """dict 输出不带 headers""" + data = {"key": "value"} + print_result(data) + output = mock_print.call_args[0][0] + parsed = json.loads(output) + self.assertEqual(parsed, {"key": "value"}) + + @patch("builtins.print") + def test_print_list_data(self, mock_print): + """list 输出不注入 RequestId""" + data = [{"item": 1}] + headers = {"x-ci-request-id": "ci-123"} + print_result(data, headers) + output = mock_print.call_args[0][0] + parsed = json.loads(output) + self.assertEqual(parsed, [{"item": 1}]) + + @patch("builtins.print") + def test_print_dict_with_empty_req_ids(self, mock_print): + """headers 没有 request id 时不注入""" + data = {"key": "value"} + headers = {"Content-Type": "application/json"} + print_result(data, headers) + output = mock_print.call_args[0][0] + parsed = json.loads(output) + self.assertNotIn("RequestId", parsed) + + +class TestSaveResponseToFile(unittest.TestCase): + """测试 save_response_to_file""" + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + def test_stream_body_response(self, mopen, mock_makedirs): + """StreamBody 直接响应""" + from qcloud_cos.cos_client import StreamBody + + mock_stream_body = MagicMock(spec=StreamBody) + mock_stream_body.__class__ = StreamBody + mock_stream_body.get_stream.return_value = [b"chunk1", b"chunk2"] + + total = save_response_to_file(mock_stream_body, "/tmp/test/output.jpg") + self.assertEqual(total, 12) + mopen.assert_called_once_with("/tmp/test/output.jpg", "wb") + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + def test_dict_body_stream_body(self, mopen, mock_makedirs): + """dict 响应中 Body 为 StreamBody""" + from qcloud_cos.cos_client import StreamBody + + mock_body = MagicMock(spec=StreamBody) + mock_body.__class__ = StreamBody + mock_body.get_stream.return_value = [b"data"] + + response = {"Body": mock_body} + total = save_response_to_file(response, "output.jpg") + self.assertEqual(total, 4) + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + def test_dict_body_raw_stream(self, mopen, mock_makedirs): + """dict 响应中 Body 为普通对象(使用 get_raw_stream)""" + mock_raw = MagicMock() + mock_raw.stream.return_value = [b"raw_chunk"] + + mock_body = MagicMock() + mock_body.get_raw_stream.return_value = mock_raw + # 确保不被识别为 StreamBody + mock_body.__class__ = type("FakeBody", (), {}) + + response = {"Body": mock_body} + total = save_response_to_file(response, "output.jpg") + self.assertEqual(total, 9) + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + def test_bytes_response(self, mopen, mock_makedirs): + """bytes 响应直接写入""" + response = b"binary_data_here" + total = save_response_to_file(response, "output.jpg") + self.assertEqual(total, 16) + + def test_unsupported_response_type(self): + """不支持的响应类型抛出 ValueError""" + with self.assertRaises(ValueError) as ctx: + save_response_to_file(12345, "/tmp/test.txt") + self.assertIn("Unsupported response type", str(ctx.exception)) + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + def test_no_output_dir(self, mopen, mock_makedirs): + """输出路径没有目录部分""" + response = b"data" + save_response_to_file(response, "output.jpg") + mock_makedirs.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tccli/plugins/ci/tests/test_doc_process.py b/tccli/plugins/ci/tests/test_doc_process.py new file mode 100644 index 0000000000..1145c5c204 --- /dev/null +++ b/tccli/plugins/ci/tests/test_doc_process.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +"""doc_process 模块单元测试""" +import os +import unittest +from unittest.mock import patch, MagicMock + +from tccli.plugins.ci.doc_process import ( + doc_preview, doc_preview_html, + doc_job_submit, doc_job_query, doc_job_list, +) + +MOCK_INIT = "tccli.plugins.ci.doc_process.init_cos_client" +MOCK_PRINT = "tccli.plugins.ci.doc_process.print_result" +MOCK_HANDLE = "tccli.plugins.ci.doc_process.handle_cos_error" +MOCK_SAVE = "tccli.plugins.ci.doc_process.save_response_to_file" +PG = {"secretId": "AK", "secretKey": "SK", "region": "ap-guangzhou"} + + +class TestDocPreview(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_SAVE, return_value=4096) + @patch(MOCK_INIT) + def test_minimal(self, mock_init, mock_save, mock_print): + client = MagicMock() + client.ci_doc_preview_process.return_value = {"Content-Type": "image/png", + "X-Total-Page": "5", "X-Total-Sheet": "1"} + mock_init.return_value = client + args = {"bucket": "b", "key": "doc.pdf"} + doc_preview(args, PG) + mock_print.assert_called_once() + result = mock_print.call_args[0][0] + self.assertEqual(result["Page"], "1") + + @patch(MOCK_PRINT) + @patch(MOCK_SAVE, return_value=2048) + @patch(MOCK_INIT) + def test_all_params(self, mock_init, mock_save, mock_print): + client = MagicMock() + client.ci_doc_preview_process.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "doc.xlsx", "page": "2", "src_type": "xlsx", + "image_type": "jpg", "dsttype": "jpg", "local_path": "/tmp/out.jpg"} + doc_preview(args, PG) + kw = client.ci_doc_preview_process.call_args[1] + self.assertEqual(kw["Page"], 2) + self.assertEqual(kw["SrcType"], "xlsx") + self.assertIn("imageMogr2/format/jpg", kw["ImageParams"]) + self.assertEqual(kw["DstType"], "jpg") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + doc_preview({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestDocPreviewHtml(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_SAVE, return_value=8192) + @patch(MOCK_INIT) + def test_with_local_path(self, mock_init, mock_save, mock_print): + client = MagicMock() + client.ci_doc_preview_html_process.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "doc.pdf", "local_path": "/tmp/out.html", "src_type": "pdf"} + doc_preview_html(args, PG) + result = mock_print.call_args[0][0] + self.assertEqual(result["Status"], "Success") + + @patch(MOCK_PRINT) + @patch(MOCK_SAVE, return_value=10240) + @patch(MOCK_INIT) + def test_without_local_path(self, mock_init, mock_save, mock_print): + client = MagicMock() + resp = {"Content-Type": "text/html"} + client.ci_doc_preview_html_process.return_value = resp + mock_init.return_value = client + args = {"bucket": "b", "key": "ci-test/doc/test.pdf"} + doc_preview_html(args, PG) + # 应自动生成文件名 test_preview.html + save_path = mock_save.call_args[0][1] + self.assertTrue(save_path.endswith("_preview.html")) + result = mock_print.call_args[0][0] + self.assertEqual(result["Status"], "Success") + self.assertEqual(result["ContentType"], "text/html") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + doc_preview_html({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestDocJobSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_minimal(self, mock_init, mock_print): + client = MagicMock() + client.ci_create_doc_job.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "doc.pdf", "output_key": "out/doc"} + doc_job_submit(args, PG) + kw = client.ci_create_doc_job.call_args[1] + self.assertEqual(kw["TgtType"], "png") + self.assertEqual(kw["OutputBucket"], "b") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_all_params(self, mock_init, mock_print): + client = MagicMock() + client.ci_create_doc_job.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "doc.xlsx", "output_key": "out/", + "output_bucket": "ob", "src_type": "xlsx", "tgt_type": "pdf", + "start_page": "1", "end_page": "10", "sheet_id": "2"} + doc_job_submit(args, PG) + kw = client.ci_create_doc_job.call_args[1] + self.assertEqual(kw["SrcType"], "xlsx") + self.assertEqual(kw["TgtType"], "pdf") + self.assertEqual(kw["StartPage"], 1) + self.assertEqual(kw["EndPage"], 10) + self.assertEqual(kw["SheetId"], 2) + self.assertEqual(kw["OutputBucket"], "ob") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + doc_job_submit({"bucket": "b", "key": "k", "output_key": "o"}, PG) + mock_handle.assert_called_once() + + +class TestDocJobQuery(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_get_doc_job.return_value = {} + mock_init.return_value = client + doc_job_query({"bucket": "b", "job_id": "j"}, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + doc_job_query({"bucket": "b", "job_id": "j"}, PG) + mock_handle.assert_called_once() + + +class TestDocJobList(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_minimal(self, mock_init, mock_print): + client = MagicMock() + client.ci_list_doc_jobs.return_value = {} + mock_init.return_value = client + doc_job_list({"bucket": "b"}, PG) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_all_params(self, mock_init, mock_print): + client = MagicMock() + client.ci_list_doc_jobs.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "size": "20", "page": "2", "status": "Success"} + doc_job_list(args, PG) + kw = client.ci_list_doc_jobs.call_args[1] + self.assertEqual(kw["Size"], 20) + self.assertEqual(kw["Page"], 2) + self.assertEqual(kw["States"], "Success") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + doc_job_list({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tccli/plugins/ci/tests/test_file_process.py b/tccli/plugins/ci/tests/test_file_process.py new file mode 100644 index 0000000000..19fbc4b932 --- /dev/null +++ b/tccli/plugins/ci/tests/test_file_process.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +"""file_process 模块单元测试""" +import unittest +from unittest.mock import patch, MagicMock + +from tccli.plugins.ci.file_process import ( + file_hash, file_hash_job_submit, + file_uncompress_submit, file_compress_submit, + file_zip_preview, file_process_jobs_query, +) + +MOCK_INIT = "tccli.plugins.ci.file_process.init_cos_client" +MOCK_PRINT = "tccli.plugins.ci.file_process.print_result" +MOCK_HANDLE = "tccli.plugins.ci.file_process.handle_cos_error" +PG = {"secretId": "AK", "secretKey": "SK", "region": "ap-guangzhou"} + + +class TestFileHash(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_basic(self, mock_init, mock_print): + client = MagicMock() + client.file_hash.return_value = {"FileHashCodeResult": {"MD5": "abc"}} + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "hash_type": "md5"} + file_hash(args, PG) + kw = client.file_hash.call_args[1] + self.assertFalse(kw["AddToHeader"]) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_add_to_header_true(self, mock_init, mock_print): + client = MagicMock() + client.file_hash.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "hash_type": "sha1", "add_to_header": "true"} + file_hash(args, PG) + kw = client.file_hash.call_args[1] + self.assertTrue(kw["AddToHeader"]) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_add_to_header_false(self, mock_init, mock_print): + client = MagicMock() + client.file_hash.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "hash_type": "sha1", "add_to_header": "false"} + file_hash(args, PG) + kw = client.file_hash.call_args[1] + self.assertFalse(kw["AddToHeader"]) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + file_hash({"bucket": "b", "key": "k", "hash_type": "md5"}, PG) + mock_handle.assert_called_once() + + +class TestFileHashJobSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_basic(self, mock_init, mock_print): + client = MagicMock() + client.ci_create_file_hash_job.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "hash_type": "md5"} + file_hash_job_submit(args, PG) + kw = client.ci_create_file_hash_job.call_args[1] + self.assertEqual(kw["FileHashCodeConfig"]["Type"], "md5") + self.assertNotIn("AddToHeader", kw["FileHashCodeConfig"]) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_add_to_header(self, mock_init, mock_print): + client = MagicMock() + client.ci_create_file_hash_job.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "k", "hash_type": "sha256", "add_to_header": "True"} + file_hash_job_submit(args, PG) + kw = client.ci_create_file_hash_job.call_args[1] + self.assertEqual(kw["FileHashCodeConfig"]["AddToHeader"], "true") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + file_hash_job_submit({"bucket": "b", "key": "k", "hash_type": "md5"}, PG) + mock_handle.assert_called_once() + + +class TestFileUncompressSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_basic(self, mock_init, mock_print): + client = MagicMock() + client.ci_create_file_uncompress_job.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "archive.zip"} + file_uncompress_submit(args, PG) + kw = client.ci_create_file_uncompress_job.call_args[1] + self.assertEqual(kw["OutputBucket"], "b") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all_params(self, mock_init, mock_print): + client = MagicMock() + client.ci_create_file_uncompress_job.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "a.zip", "password": "pwd123", + "output_prefix": "unzipped/", "output_bucket": "ob"} + file_uncompress_submit(args, PG) + kw = client.ci_create_file_uncompress_job.call_args[1] + self.assertEqual(kw["FileUncompressConfig"]["UnCompressKey"], "pwd123") + self.assertEqual(kw["FileUncompressConfig"]["Prefix"], "unzipped/") + self.assertEqual(kw["OutputBucket"], "ob") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + file_uncompress_submit({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestFileCompressSubmit(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_basic(self, mock_init, mock_print): + client = MagicMock() + client.ci_create_file_compress_job.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "keys": "a.txt,b.txt", "output_key": "out.zip"} + file_compress_submit(args, PG) + kw = client.ci_create_file_compress_job.call_args[1] + self.assertEqual(kw["FileCompressConfig"]["Key"], ["a.txt", "b.txt"]) + self.assertEqual(kw["FileCompressConfig"]["Format"], "zip") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_prefix_and_format(self, mock_init, mock_print): + client = MagicMock() + client.ci_create_file_compress_job.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "keys": "a.txt", "output_key": "out.tar.gz", + "compress_format": "tar.gz", "prefix": "dir/"} + file_compress_submit(args, PG) + kw = client.ci_create_file_compress_job.call_args[1] + self.assertEqual(kw["FileCompressConfig"]["Format"], "tar.gz") + self.assertEqual(kw["FileCompressConfig"]["Prefix"], "dir/") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + file_compress_submit({"bucket": "b", "keys": "k", "output_key": "o"}, PG) + mock_handle.assert_called_once() + + +class TestFileZipPreview(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_tuple_response(self, mock_init, mock_print): + client = MagicMock() + client.ci_file_zip_preview.return_value = ({"x-ci-request-id": "r"}, {"FileList": []}) + mock_init.return_value = client + file_zip_preview({"bucket": "b", "key": "a.zip"}, PG) + mock_print.assert_called_once_with({"FileList": []}, {"x-ci-request-id": "r"}) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_non_tuple_response(self, mock_init, mock_print): + client = MagicMock() + client.ci_file_zip_preview.return_value = {"FileList": []} + mock_init.return_value = client + file_zip_preview({"bucket": "b", "key": "a.zip"}, PG) + mock_print.assert_called_once_with({"FileList": []}) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + file_zip_preview({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestFileProcessJobsQuery(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_get_file_process_jobs.return_value = {} + mock_init.return_value = client + file_process_jobs_query({"bucket": "b", "job_id": "j"}, PG) + client.ci_get_file_process_jobs.assert_called_once_with(Bucket="b", JobIDs="j") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + file_process_jobs_query({"bucket": "b", "job_id": "j"}, PG) + mock_handle.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tccli/plugins/ci/tests/test_image_process.py b/tccli/plugins/ci/tests/test_image_process.py new file mode 100644 index 0000000000..b8df399ff5 --- /dev/null +++ b/tccli/plugins/ci/tests/test_image_process.py @@ -0,0 +1,482 @@ +# -*- coding: utf-8 -*- +"""image_process 模块单元测试 + +覆盖 10 个函数的成功路径、错误路径、以及关键分支: +- image_process: local_path 有/无 +- image_process_saveas: objects 为 dict / list +- image_upload_process: savekey 有/无, 本地文件不存在, objects 为 dict / list +- image_info: data 为 bytes / dict +- image_exif_info: bytes / tuple +- image_ave_color: bytes / tuple +- image_inspect: bytes / tuple +- image_style_add/get/delete: 正常/异常 +- image_style_get: style_name 有/无, tuple 返回/非 tuple +""" +import json +import os +import unittest +from unittest.mock import patch, MagicMock + +from tccli.plugins.ci.image_process import ( + image_process, + image_process_saveas, + image_upload_process, + image_info, + image_exif_info, + image_ave_color, + image_inspect, + image_style_add, + image_style_get, + image_style_delete, +) + +MOCK_INIT = "tccli.plugins.ci.image_process.init_cos_client" +MOCK_PRINT = "tccli.plugins.ci.image_process.print_result" +MOCK_HANDLE = "tccli.plugins.ci.image_process.handle_cos_error" + +PG = {"secretId": "AK", "secretKey": "SK", "region": "ap-guangzhou"} + + +class TestImageProcess(unittest.TestCase): + """image_process 函数测试""" + + @patch(MOCK_PRINT) + @patch("os.path.getsize", return_value=1024) + @patch("os.path.exists", return_value=True) + @patch("os.makedirs") + @patch(MOCK_INIT) + def test_success_with_local_path(self, mock_init, mock_makedirs, mock_exists, mock_size, mock_print): + client = MagicMock() + client.ci_get_object.return_value = {"Content-Type": "image/jpeg", "x-cos-request-id": "req1"} + mock_init.return_value = client + + args = {"bucket": "b", "key": "dir/test.jpg", "rule": "imageView2/2/w/800", "local_path": "/tmp/out.jpg"} + image_process(args, PG) + client.ci_get_object.assert_called_once() + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch("os.path.getsize", return_value=512) + @patch("os.path.exists", return_value=True) + @patch(MOCK_INIT) + def test_success_default_local_path(self, mock_init, mock_exists, mock_size, mock_print): + """local_path 未指定时使用 basename(key)""" + client = MagicMock() + client.ci_get_object.return_value = {"Content-Type": "image/png"} + mock_init.return_value = client + + args = {"bucket": "b", "key": "test.png", "rule": "imageView2/2/w/800"} + image_process(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error_handling(self, mock_init, mock_handle): + mock_init.side_effect = Exception("network error") + args = {"bucket": "b", "key": "test.jpg", "rule": "imageView2/2/w/800"} + image_process(args, PG) + mock_handle.assert_called_once() + + @patch(MOCK_PRINT) + @patch("os.path.getsize", return_value=0) + @patch("os.path.exists", return_value=False) + @patch(MOCK_INIT) + def test_file_not_exists_after_download(self, mock_init, mock_exists, mock_size, mock_print): + """下载后文件不存在,ContentLength 为 0""" + client = MagicMock() + client.ci_get_object.return_value = {} + mock_init.return_value = client + args = {"bucket": "b", "key": "test.jpg", "rule": "r"} + image_process(args, PG) + result = mock_print.call_args[0][0] + self.assertEqual(result["ContentLength"], "0") + + +class TestImageProcessSaveas(unittest.TestCase): + """image_process_saveas 函数测试""" + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success_objects_list(self, mock_init, mock_print): + """ProcessResults.Object 为 list""" + client = MagicMock() + resp_headers = {"x-cos-request-id": "req1"} + data = { + "OriginalInfo": {"ImageInfo": {"Format": "jpeg", "Width": "800", "Height": "600"}}, + "ProcessResults": {"Object": [{"Key": "out.jpg", "ETag": "etag1", "Format": "jpeg", + "Width": "400", "Height": "300", "Size": "1024", "Quality": "80"}]}, + } + client.ci_image_process.return_value = (resp_headers, data) + mock_init.return_value = client + + args = {"bucket": "b", "key": "test.jpg", "rule": "r", "savekey": "out.jpg"} + image_process_saveas(args, PG) + mock_print.assert_called_once() + result = mock_print.call_args[0][0] + self.assertEqual(len(result["ProcessedImages"]), 1) + self.assertIn("OriginalImage", result) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success_objects_dict(self, mock_init, mock_print): + """ProcessResults.Object 为 dict(单个结果)""" + client = MagicMock() + resp_headers = {"x-cos-request-id": "req1"} + data = { + "OriginalInfo": {"ImageInfo": {}}, + "ProcessResults": {"Object": {"Key": "out.jpg", "ETag": "e", "Format": "png", + "Width": "400", "Height": "300", "Size": "512", "Quality": "90"}}, + } + client.ci_image_process.return_value = (resp_headers, data) + mock_init.return_value = client + + args = {"bucket": "b", "key": "test.jpg", "rule": "r", "savekey": "out.jpg"} + image_process_saveas(args, PG) + result = mock_print.call_args[0][0] + self.assertEqual(len(result["ProcessedImages"]), 1) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_no_original_info(self, mock_init, mock_print): + """OriginalInfo.ImageInfo 为空""" + client = MagicMock() + client.ci_image_process.return_value = ( + {"x-cos-request-id": "r"}, + {"OriginalInfo": {"ImageInfo": {}}, "ProcessResults": {"Object": []}}, + ) + mock_init.return_value = client + + args = {"bucket": "b", "key": "t.jpg", "rule": "r", "savekey": "o.jpg"} + image_process_saveas(args, PG) + result = mock_print.call_args[0][0] + self.assertNotIn("OriginalImage", result) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + image_process_saveas({"bucket": "b", "key": "k", "rule": "r", "savekey": "s"}, PG) + mock_handle.assert_called_once() + + +class TestImageUploadProcess(unittest.TestCase): + """image_upload_process 函数测试""" + + @patch(MOCK_PRINT) + @patch("os.path.isfile", return_value=True) + @patch(MOCK_INIT) + def test_success_with_savekey(self, mock_init, mock_isfile, mock_print): + """指定 savekey""" + client = MagicMock() + client.ci_put_object_from_local_file.return_value = ( + {"x-cos-request-id": "r"}, + {"OriginalInfo": {"ImageInfo": {"Format": "png", "Width": "100", "Height": "100"}}, + "ProcessResults": {"Object": []}}, + ) + mock_init.return_value = client + + args = {"bucket": "b", "local": "/tmp/test.jpg", "key": "test.jpg", + "rule": "r", "savekey": "out.jpg"} + image_upload_process(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch("os.path.isfile", return_value=True) + @patch(MOCK_INIT) + def test_success_default_savekey(self, mock_init, mock_isfile, mock_print): + """savekey 未指定时使用 key""" + client = MagicMock() + client.ci_put_object_from_local_file.return_value = ( + {"x-cos-request-id": "r"}, + {"OriginalInfo": {"ImageInfo": {}}, "ProcessResults": {"Object": []}}, + ) + mock_init.return_value = client + + args = {"bucket": "b", "local": "/tmp/test.jpg", "key": "test.jpg", "rule": "r"} + image_upload_process(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch("os.path.isfile", return_value=False) + @patch(MOCK_INIT) + def test_file_not_found(self, mock_init, mock_isfile, mock_handle): + """本地文件不存在""" + mock_init.return_value = MagicMock() + args = {"bucket": "b", "local": "/nonexist.jpg", "key": "k", "rule": "r"} + image_upload_process(args, PG) + mock_handle.assert_called_once() + err = mock_handle.call_args[0][0] + self.assertIsInstance(err, FileNotFoundError) + + @patch(MOCK_PRINT) + @patch("os.path.isfile", return_value=True) + @patch(MOCK_INIT) + def test_objects_dict(self, mock_init, mock_isfile, mock_print): + """Object 为 dict""" + client = MagicMock() + client.ci_put_object_from_local_file.return_value = ( + {}, {"OriginalInfo": {"ImageInfo": {}}, + "ProcessResults": {"Object": {"Key": "k", "ETag": "e", "Format": "f", + "Width": "w", "Height": "h", "Size": "s", "Quality": "q"}}}, + ) + mock_init.return_value = client + args = {"bucket": "b", "local": "/tmp/t.jpg", "key": "k", "rule": "r", "savekey": "s"} + image_upload_process(args, PG) + result = mock_print.call_args[0][0] + self.assertEqual(len(result["ProcessedImages"]), 1) + + +class TestImageInfo(unittest.TestCase): + """image_info 函数测试""" + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success_dict(self, mock_init, mock_print): + """data 返回为 dict""" + client = MagicMock() + client.ci_get_image_info.return_value = ({"x-ci-request-id": "r"}, {"format": "png"}) + mock_init.return_value = client + + image_info({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once_with({"format": "png"}, {"x-ci-request-id": "r"}) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success_bytes(self, mock_init, mock_print): + """data 返回为 bytes""" + client = MagicMock() + client.ci_get_image_info.return_value = ( + {"x-ci-request-id": "r"}, + json.dumps({"format": "jpeg"}).encode(), + ) + mock_init.return_value = client + + image_info({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once() + data = mock_print.call_args[0][0] + self.assertEqual(data["format"], "jpeg") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + image_info({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestImageExifInfo(unittest.TestCase): + """image_exif_info 函数测试""" + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_bytes_response(self, mock_init, mock_print): + """SDK 返回 bytes""" + client = MagicMock() + client.ci_get_image_exif_info.return_value = json.dumps({"exif": "data"}).encode() + mock_init.return_value = client + + image_exif_info({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once() + data = mock_print.call_args[0][0] + self.assertEqual(data["exif"], "data") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_tuple_response_bytes_data(self, mock_init, mock_print): + """SDK 返回 (headers, bytes_data) 元组""" + client = MagicMock() + client.ci_get_image_exif_info.return_value = ( + {"x-ci-request-id": "r"}, + json.dumps({"exif": "info"}).encode(), + ) + mock_init.return_value = client + + image_exif_info({"bucket": "b", "key": "k"}, PG) + mock_print.assert_called_once() + data = mock_print.call_args[0][0] + self.assertEqual(data["exif"], "info") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_tuple_response_dict_data(self, mock_init, mock_print): + """SDK 返回 (headers, dict_data) 元组""" + client = MagicMock() + client.ci_get_image_exif_info.return_value = ( + {"x-ci-request-id": "r"}, + {"exif": "dict_data"}, + ) + mock_init.return_value = client + + image_exif_info({"bucket": "b", "key": "k"}, PG) + data = mock_print.call_args[0][0] + self.assertEqual(data["exif"], "dict_data") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + image_exif_info({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestImageAveColor(unittest.TestCase): + """image_ave_color 函数测试""" + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_bytes_response(self, mock_init, mock_print): + client = MagicMock() + client.ci_get_image_ave_info.return_value = json.dumps({"RGB": "0x123456"}).encode() + mock_init.return_value = client + image_ave_color({"bucket": "b", "key": "k"}, PG) + data = mock_print.call_args[0][0] + self.assertEqual(data["RGB"], "0x123456") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_tuple_response(self, mock_init, mock_print): + client = MagicMock() + client.ci_get_image_ave_info.return_value = ( + {"x-ci-request-id": "r"}, + json.dumps({"RGB": "0xABCDEF"}).encode(), + ) + mock_init.return_value = client + image_ave_color({"bucket": "b", "key": "k"}, PG) + data = mock_print.call_args[0][0] + self.assertEqual(data["RGB"], "0xABCDEF") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + image_ave_color({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestImageInspect(unittest.TestCase): + """image_inspect 异常图片检测函数测试""" + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_bytes_response_normal_image(self, mock_init, mock_print): + """正常图片 - SDK 返回 bytes""" + client = MagicMock() + client.ci_image_inspect.return_value = json.dumps({ + "picSize": 158421, + "picType": "jpeg", + "suspicious": False, + }).encode() + mock_init.return_value = client + image_inspect({"bucket": "b", "key": "k"}, PG) + data = mock_print.call_args[0][0] + self.assertEqual(data["picSize"], 158421) + self.assertEqual(data["picType"], "jpeg") + self.assertFalse(data["suspicious"]) + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_tuple_response_suspicious_image(self, mock_init, mock_print): + """可疑图片 - SDK 返回 (headers, bytes_data) 元组""" + client = MagicMock() + client.ci_image_inspect.return_value = ( + {"x-ci-request-id": "r"}, + json.dumps({ + "picSize": 1048576, + "picType": "png", + "suspicious": True, + "suspiciousBeginByte": "524288", + "suspiciousEndByte": "1048575", + "suspiciousSize": "524288", + "suspiciousType": "MPEG-TS", + }).encode(), + ) + mock_init.return_value = client + image_inspect({"bucket": "b", "key": "k"}, PG) + data = mock_print.call_args[0][0] + self.assertTrue(data["suspicious"]) + self.assertEqual(data["suspiciousType"], "MPEG-TS") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + image_inspect({"bucket": "b", "key": "k"}, PG) + mock_handle.assert_called_once() + + +class TestImageStyleAdd(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_put_image_style.return_value = {"x-cos-request-id": "r"} + mock_init.return_value = client + args = {"bucket": "b", "style_name": "thumb", "style_body": "imageView2/2/w/200"} + image_style_add(args, PG) + client.ci_put_image_style.assert_called_once_with( + Bucket="b", Request={"StyleName": "thumb", "StyleBody": "imageView2/2/w/200"}) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + image_style_add({"bucket": "b", "style_name": "s", "style_body": "body"}, PG) + mock_handle.assert_called_once() + + +class TestImageStyleGet(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_style_name_tuple(self, mock_init, mock_print): + """指定 style_name,返回 tuple""" + client = MagicMock() + client.ci_get_image_style.return_value = ( + {"x-ci-request-id": "r"}, + {"StyleRule": [{"StyleName": "thumb"}]}, + ) + mock_init.return_value = client + args = {"bucket": "b", "style_name": "thumb"} + image_style_get(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_without_style_name_non_tuple(self, mock_init, mock_print): + """不指定 style_name,返回非 tuple""" + client = MagicMock() + client.ci_get_image_style.return_value = {"StyleRule": []} + mock_init.return_value = client + args = {"bucket": "b"} + image_style_get(args, PG) + mock_print.assert_called_once_with({"StyleRule": []}, None) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + image_style_get({"bucket": "b"}, PG) + mock_handle.assert_called_once() + + +class TestImageStyleDelete(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_delete_image_style.return_value = {"x-cos-request-id": "r"} + mock_init.return_value = client + args = {"bucket": "b", "style_name": "thumb"} + image_style_delete(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + image_style_delete({"bucket": "b", "style_name": "s"}, PG) + mock_handle.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tccli/plugins/ci/tests/test_media_process.py b/tccli/plugins/ci/tests/test_media_process.py new file mode 100644 index 0000000000..b66af0c444 --- /dev/null +++ b/tccli/plugins/ci/tests/test_media_process.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- +"""media_process 模块单元测试 + +覆盖目标: +- video_snapshot: 可选参数全覆盖 (width/height/mode/rotate/format/local_path) +- _xml_to_dict: 叶子节点 / 非叶子 / 同名多节点 / 命名空间 +- media_info: 正常 +- media_job_submit: operation 有/无, output_key 有/无, queue_id/callback +- media_job_query: 正常 +- media_job_list: 可选参数全覆盖 +- media_job_cancel: 正常 +- 所有函数的异常处理 +""" +import json +import os +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import patch, MagicMock + +from tccli.plugins.ci.media_process import ( + video_snapshot, + _xml_to_dict, + media_info, + media_job_submit, + media_job_query, + media_job_list, + media_job_cancel, +) + +MOCK_INIT = "tccli.plugins.ci.media_process.init_cos_client" +MOCK_PRINT = "tccli.plugins.ci.media_process.print_result" +MOCK_HANDLE = "tccli.plugins.ci.media_process.handle_cos_error" +MOCK_SAVE = "tccli.plugins.ci.media_process.save_response_to_file" + +PG = {"secretId": "AK", "secretKey": "SK", "region": "ap-guangzhou"} + + +class TestXmlToDict(unittest.TestCase): + """_xml_to_dict 函数测试""" + + def test_simple_leaf(self): + xml_str = "test100" + root = ET.fromstring(xml_str) + result = _xml_to_dict(root) + self.assertEqual(result, {"Name": "test", "Size": "100"}) + + def test_nested(self): + xml_str = "value" + root = ET.fromstring(xml_str) + result = _xml_to_dict(root) + self.assertEqual(result, {"Parent": {"Child": "value"}}) + + def test_multiple_same_tags(self): + xml_str = "abc" + root = ET.fromstring(xml_str) + result = _xml_to_dict(root) + self.assertEqual(result, {"Item": ["a", "b", "c"]}) + + def test_namespace(self): + xml_str = 'test' + root = ET.fromstring(xml_str) + result = _xml_to_dict(root) + self.assertEqual(result, {"Name": "test"}) + + def test_empty_text(self): + xml_str = "" + root = ET.fromstring(xml_str) + result = _xml_to_dict(root) + self.assertEqual(result, {"Empty": ""}) + + def test_empty_root(self): + xml_str = "" + root = ET.fromstring(xml_str) + result = _xml_to_dict(root) + self.assertEqual(result, {}) + + def test_two_same_then_third(self): + """第一个元素为值,第二个相同 tag 时转为 list""" + xml_str = "ab" + root = ET.fromstring(xml_str) + result = _xml_to_dict(root) + self.assertIsInstance(result["Tag"], list) + self.assertEqual(result["Tag"], ["a", "b"]) + + +class TestVideoSnapshot(unittest.TestCase): + """video_snapshot 函数测试""" + + @patch(MOCK_PRINT) + @patch(MOCK_SAVE, return_value=2048) + @patch(MOCK_INIT) + def test_success_minimal(self, mock_init, mock_save, mock_print): + """最小参数""" + client = MagicMock() + client.get_snapshot.return_value = {"Content-Type": "image/jpeg"} + mock_init.return_value = client + + args = {"bucket": "b", "key": "video.mp4", "snapshot_time": "1.5"} + video_snapshot(args, PG) + client.get_snapshot.assert_called_once() + mock_save.assert_called_once() + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_SAVE, return_value=4096) + @patch(MOCK_INIT) + def test_success_all_params(self, mock_init, mock_save, mock_print): + """全部可选参数""" + client = MagicMock() + client.get_snapshot.return_value = {"Content-Type": "image/png"} + mock_init.return_value = client + + args = { + "bucket": "b", "key": "dir/video.mp4", "snapshot_time": "3", + "width": "640", "height": "480", "format": "png", + "mode": "exactframe", "rotate": "auto", + "local_path": "/tmp/snap.png", + } + video_snapshot(args, PG) + call_kwargs = client.get_snapshot.call_args[1] + self.assertEqual(call_kwargs["Width"], 640) + self.assertEqual(call_kwargs["Height"], 480) + self.assertEqual(call_kwargs["Mode"], "exactframe") + self.assertEqual(call_kwargs["Rotate"], "auto") + self.assertEqual(call_kwargs["Format"], "png") + + @patch(MOCK_PRINT) + @patch(MOCK_SAVE, return_value=1024) + @patch(MOCK_INIT) + def test_empty_optional_params(self, mock_init, mock_save, mock_print): + """可选参数为空字符串时不传""" + client = MagicMock() + client.get_snapshot.return_value = {} + mock_init.return_value = client + + args = { + "bucket": "b", "key": "v.mp4", "snapshot_time": "0", + "width": "", "height": "", "mode": "", "rotate": "", + } + video_snapshot(args, PG) + call_kwargs = client.get_snapshot.call_args[1] + self.assertNotIn("Width", call_kwargs) + self.assertNotIn("Height", call_kwargs) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + video_snapshot({"bucket": "b", "key": "v.mp4", "snapshot_time": "1"}, PG) + mock_handle.assert_called_once() + + +class TestMediaInfo(unittest.TestCase): + """media_info 函数测试""" + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.get_media_info.return_value = {"MediaInfo": {"Format": {"Duration": "60"}}} + mock_init.return_value = client + media_info({"bucket": "b", "key": "v.mp4"}, PG) + mock_print.assert_called_once_with({"MediaInfo": {"Format": {"Duration": "60"}}}) + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + media_info({"bucket": "b", "key": "v.mp4"}, PG) + mock_handle.assert_called_once() + + +class TestMediaJobSubmit(unittest.TestCase): + """media_job_submit 函数测试""" + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_minimal(self, mock_init, mock_print): + """最小参数,无 operation""" + client = MagicMock() + client.ci_create_media_jobs.return_value = {"JobsDetail": {"JobId": "j1"}} + mock_init.return_value = client + + args = {"bucket": "b", "key": "v.mp4", "tag": "Transcode"} + media_job_submit(args, PG) + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_with_all_params(self, mock_init, mock_print): + """全部参数""" + client = MagicMock() + client.ci_create_media_jobs.return_value = {"JobsDetail": {}} + mock_init.return_value = client + + args = { + "bucket": "b", "key": "v.mp4", "tag": "Transcode", + "operation": '{"Transcode":{"Container":{"Format":"mp4"}}}', + "output_key": "out.mp4", + "output_bucket": "ob", + "output_region": "ap-beijing", + "queue_id": "q1", + "callback": "http://cb.example.com", + } + media_job_submit(args, PG) + call_kwargs = client.ci_create_media_jobs.call_args[1] + jobs = call_kwargs["Jobs"] + self.assertIn("QueueId", jobs) + self.assertIn("CallBack", jobs) + self.assertEqual(jobs["Operation"]["Output"]["Bucket"], "ob") + self.assertEqual(jobs["Operation"]["Output"]["Region"], "ap-beijing") + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_output_key_default_bucket(self, mock_init, mock_print): + """output_key 有但 output_bucket 无时用 bucket""" + client = MagicMock() + client.ci_create_media_jobs.return_value = {} + mock_init.return_value = client + + args = {"bucket": "b", "key": "v.mp4", "tag": "Snapshot", "output_key": "snap.jpg"} + media_job_submit(args, PG) + jobs = client.ci_create_media_jobs.call_args[1]["Jobs"] + self.assertEqual(jobs["Operation"]["Output"]["Bucket"], "b") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + media_job_submit({"bucket": "b", "key": "k", "tag": "T"}, PG) + mock_handle.assert_called_once() + + +class TestMediaJobQuery(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_get_media_jobs.return_value = {"JobsDetail": {"State": "Success"}} + mock_init.return_value = client + media_job_query({"bucket": "b", "job_id": "j1"}, PG) + client.ci_get_media_jobs.assert_called_once_with(Bucket="b", JobIDs="j1") + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + media_job_query({"bucket": "b", "job_id": "j1"}, PG) + mock_handle.assert_called_once() + + +class TestMediaJobList(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_minimal(self, mock_init, mock_print): + client = MagicMock() + client.ci_list_media_jobs.return_value = {"JobsDetail": []} + mock_init.return_value = client + media_job_list({"bucket": "b", "tag": "Transcode"}, PG) + mock_print.assert_called_once() + + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_all_optional_params(self, mock_init, mock_print): + """全部可选参数""" + client = MagicMock() + client.ci_list_media_jobs.return_value = {} + mock_init.return_value = client + + args = { + "bucket": "b", "tag": "Transcode", + "queue_id": "q1", "status": "Success", + "size": "20", "next_token": "abc", + "order_by_time": "Desc", + } + media_job_list(args, PG) + call_kwargs = client.ci_list_media_jobs.call_args[1] + self.assertEqual(call_kwargs["QueueId"], "q1") + self.assertEqual(call_kwargs["States"], "Success") + self.assertEqual(call_kwargs["Size"], 20) + self.assertEqual(call_kwargs["NextToken"], "abc") + self.assertEqual(call_kwargs["OrderByTime"], "Desc") + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + media_job_list({"bucket": "b", "tag": "T"}, PG) + mock_handle.assert_called_once() + + +class TestMediaJobCancel(unittest.TestCase): + @patch(MOCK_PRINT) + @patch(MOCK_INIT) + def test_success(self, mock_init, mock_print): + client = MagicMock() + client.ci_cancel_jobs.return_value = {} + mock_init.return_value = client + media_job_cancel({"bucket": "b", "job_id": "j1"}, PG) + client.ci_cancel_jobs.assert_called_once_with(Bucket="b", JobID="j1") + mock_print.assert_called_once() + + @patch(MOCK_HANDLE) + @patch(MOCK_INIT) + def test_error(self, mock_init, mock_handle): + mock_init.side_effect = Exception("err") + media_job_cancel({"bucket": "b", "job_id": "j1"}, PG) + mock_handle.assert_called_once() + + +if __name__ == "__main__": + unittest.main()