"""批量压测结果 xlsx 实时同步至 GitCode 仓(Alice_zhou/issue)
多机实时汇聚:任何机器(本地压测机 / 远程压测机 / 其他执行机)每完成一个场景压测更新本地
out_res/批量压测结果.xlsx 后,立即将结果合并推送到 https://gitcode.com/Alice_zhou/issue 仓,
仓内路径默认根目录 `批量压测结果.xlsx`(config/issue_tracker.json 的 batch_result_repo_path 可配)。
不论压测任务由哪台机器拉起,仓内始终只有一份汇聚后的最新结果表。
合并语义(多机安全,不丢任何机器的数据):
- 行键 = 接口名 + 测试场景(与本地 upsert 语义一致)
- 同步时先 GET 远端 xlsx → 以远端为基底,将本机行 upsert 合并进去(键冲突时本机行优先,
本机行是刚压测完的最新结果)
- 远端存在而本机没有的行(其他机器写入的数据)原样保留,禁止整表覆盖
- PUT 携带远端文件 sha(乐观锁);多机并发推送冲突(409/sha 失配)时自动重拉远端重新
合并重试(最多 --retries 次),保证最后推送者包含全部机器的行
- 远端文件不存在(首次同步)时 POST 创建
API(已实测验证,同 scripts/upload_to_issue_repo.py):
- GET {api_base}/repos/{owner}/{repo}/contents/{path}?ref={branch}
- POST {api_base}/repos/{owner}/{repo}/contents/{path} JSON {content(base64), message, branch}
- PUT {api_base}/repos/{owner}/{repo}/contents/{path} JSON {content, message, branch, sha}
- 认证:PRIVATE-TOKEN 头(config/issue_tracker.json access_token,支持 ${GITCODE_TOKEN})
用法:
python scripts/sync_batch_results_git.py # 同步本地默认 xlsx(手动补推)
python scripts/sync_batch_results_git.py --xlsx out_res/批量压测结果.xlsx --json
python scripts/sync_batch_results_git.py --repo_path results/批量压测结果.xlsx # 自定义仓内路径
在 scripts/update_batch_results.py 中自动调用:每次本地表格更新成功后实时推送到 GitCode 仓
(--no_git_sync 可跳过本次;config/issue_tracker.json 的 batch_result_git_sync=false 可全局关闭;
环境变量 BATCH_RESULT_GIT_SYNC=0/1 优先级介于两者之间)。
"""
import argparse
import base64
import io
import json
import os
import socket
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
try:
import openpyxl
except ImportError:
openpyxl = None
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
DEFAULT_CONFIG = "config/issue_tracker.json"
DEFAULT_XLSX = os.path.join("out_res", "批量压测结果.xlsx")
DEFAULT_REPO_PATH = "批量压测结果.xlsx"
MAX_FILE_BYTES = 50 * 1024 * 1024
HEADERS = [
"接口名",
"测试场景",
"压测总条数",
"实际执行成功条数",
"实际执行失败条数",
"执行失败原因",
"是否存在bug",
"是否存在aicore",
"执行卡号",
"更新时间",
"是否分析完成",
"问题单",
]
def log(tag, msg):
print("[{}] {}".format(tag, msg))
class GitCodeContentsClient:
"""GitCode 仓文件操作客户端(复用 issue_tracker.json 认证与 issue 附件仓配置)。"""
def __init__(self, config_path=DEFAULT_CONFIG):
if not os.path.isfile(config_path):
raise RuntimeError("配置文件不存在: {}(参考 config/issue_tracker.example.json)".format(config_path))
cfg = json.load(open(config_path, encoding="utf-8"))
self.api_base = cfg.get("api_base", "https://gitcode.com/api/v5").rstrip("/")
self.token = cfg.get("access_token", "")
if self.token.startswith("${") and self.token.endswith("}"):
self.token = os.environ.get(self.token[2:-1], "")
if not self.token:
raise RuntimeError("access_token 未配置(config/issue_tracker.json 或环境变量)")
self.repo_owner = cfg.get("issue_repo_owner", "Alice_zhou")
self.repo_name = cfg.get("issue_repo_name", "issue")
self.branch = cfg.get("issue_repo_branch", "main")
self.repo_path = cfg.get("batch_result_repo_path", DEFAULT_REPO_PATH)
def _url(self, path):
quoted = urllib.parse.quote(path.strip("/"), safe="/")
return "{}/repos/{}/{}/contents/{}".format(
self.api_base, self.repo_owner, self.repo_name, quoted)
def _request(self, path, method="GET", data=None):
body = json.dumps(data).encode("utf-8") if data else None
req = urllib.request.Request(
self._url(path) + ("?ref=" + urllib.parse.quote(self.branch) if method == "GET" else ""),
data=body, method=method,
headers={"PRIVATE-TOKEN": self.token, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
try:
return exc.code, json.loads(exc.read().decode("utf-8"))
except Exception:
return exc.code, {}
except Exception as exc:
return -1, {"error_message": str(exc)}
def get_file(self, path):
return self._request(path, "GET")
def create_file(self, path, content_b64, message):
return self._request(path, "POST", {
"content": content_b64, "message": message, "branch": self.branch})
def update_file(self, path, content_b64, message, sha):
return self._request(path, "PUT", {
"content": content_b64, "message": message, "branch": self.branch, "sha": sha})
def _cell_str(value):
return str(value).strip() if value is not None else ""
def _row_key(ws, row):
return (_cell_str(ws.cell(row=row, column=1).value),
_cell_str(ws.cell(row=row, column=2).value))
def merge_local_into_remote(remote_bytes, local_xlsx):
"""以远端 xlsx 为基底合并本机行(行键=接口名+测试场景,本机行优先),返回合并后 xlsx 字节。
远端存在而本机没有的行原样保留(其他机器的数据不被覆盖);本机行覆盖远端同键行
(本机行是本次刚压测完的最新结果)。远端解析失败时抛异常(禁止用本机整表覆盖远端,
防止丢其他机器数据)。
"""
if openpyxl is None:
raise RuntimeError("openpyxl 未安装,无法合并(pip install openpyxl)")
remote_wb = openpyxl.load_workbook(io.BytesIO(remote_bytes))
remote_ws = remote_wb.active
local_wb = openpyxl.load_workbook(local_xlsx)
local_ws = local_wb.active
if remote_ws.max_row < 1 or _cell_str(remote_ws.cell(row=1, column=1).value) != HEADERS[0]:
if remote_ws.max_row >= 1 and _cell_str(remote_ws.cell(row=1, column=1).value) == "":
header_row = 1
else:
header_row = remote_ws.max_row + 1
for col_idx, name in enumerate(HEADERS, start=1):
remote_ws.cell(row=header_row, column=col_idx, value=name)
remote_map = {}
for row in range(2, remote_ws.max_row + 1):
key = _row_key(remote_ws, row)
if key[0]:
remote_map[key] = row
merged, appended = 0, 0
for row in range(2, local_ws.max_row + 1):
key = _row_key(local_ws, row)
if not key[0]:
continue
if key in remote_map:
target = remote_map[key]
merged += 1
else:
target = remote_ws.max_row + 1
appended += 1
for col_idx in range(1, len(HEADERS) + 1):
value = local_ws.cell(row=row, column=col_idx).value
cell = remote_ws.cell(row=target, column=col_idx, value=value)
if col_idx == 12 and value and _cell_str(value).startswith("http"):
first_url = _cell_str(value).split("\n")[0]
if first_url.startswith("http"):
cell.hyperlink = first_url
buf = io.BytesIO()
remote_wb.save(buf)
return buf.getvalue(), merged, appended
def sync_local_xlsx(local_xlsx=DEFAULT_XLSX, repo_path=None, config_path=DEFAULT_CONFIG,
commit_msg=None, retries=3):
"""将本地结果表合并推送到 GitCode 仓(Alice_zhou/issue)。
返回 (ok, url, detail):ok=True 时 url 为下载/页面链接,detail 为可读描述;
ok=False 时 url 为空、detail 为失败原因。
"""
if not os.path.isfile(local_xlsx):
return False, "", "本地文件不存在: {}".format(local_xlsx)
size = os.path.getsize(local_xlsx)
if size > MAX_FILE_BYTES:
return False, "", "文件超过 {}MB 上限".format(MAX_FILE_BYTES // (1024 * 1024))
client = GitCodeContentsClient(config_path)
path = repo_path or client.repo_path
host = socket.gethostname()
if not commit_msg:
commit_msg = "results: sync {} from {}".format(os.path.basename(local_xlsx), host)
last_err = ""
for attempt in range(1, retries + 1):
code, info = client.get_file(path)
if code == 200:
sha = info.get("sha", "")
content_b64 = info.get("content", "") or ""
remote_bytes = base64.b64decode(content_b64) if content_b64 else b""
if openpyxl is None:
payload_b64 = base64.b64encode(open(local_xlsx, "rb").read()).decode("ascii")
merged = appended = -1
else:
try:
merged_bytes, merged, appended = merge_local_into_remote(remote_bytes, local_xlsx)
except Exception as exc:
return False, "", "远端文件合并失败(禁止覆盖远端,已中止): {}".format(exc)
payload_b64 = base64.b64encode(merged_bytes).decode("ascii")
code2, resp = client.update_file(path, payload_b64, commit_msg, sha)
if code2 in (200, 201):
dl = info.get("download_url", "")
html = info.get("html_url", "")
detail = "merge(remote+local) merged={} appended={} host={}".format(
merged, appended, host)
return True, dl or html, detail
last_err = "PUT 失败 [{}]: {}".format(code2, json.dumps(resp, ensure_ascii=False)[:200])
if code2 in (409, 400, 422):
time.sleep(min(1.0 * attempt, 3.0))
continue
elif code == 404:
payload_b64 = base64.b64encode(open(local_xlsx, "rb").read()).decode("ascii")
code2, resp = client.create_file(path, payload_b64, commit_msg)
if code2 in (200, 201):
code3, info2 = client.get_file(path)
dl = info2.get("download_url", "") if code3 == 200 else ""
html = info2.get("html_url", "") if code3 == 200 else ""
return True, dl or html, "create(new) host={}".format(host)
last_err = "POST 失败 [{}]: {}".format(code2, json.dumps(resp, ensure_ascii=False)[:200])
time.sleep(min(1.0 * attempt, 3.0))
else:
last_err = "GET 失败 [{}]: {}".format(code, json.dumps(info, ensure_ascii=False)[:200])
time.sleep(min(1.0 * attempt, 3.0))
return False, "", "重试 {} 次仍失败: {}".format(retries, last_err)
def git_sync_enabled(config_path=DEFAULT_CONFIG):
"""读取同步开关:环境变量 BATCH_RESULT_GIT_SYNC > config batch_result_git_sync(默认 true)。"""
env = os.environ.get("BATCH_RESULT_GIT_SYNC", "").strip()
if env:
return env not in ("0", "false", "off", "no"), "env BATCH_RESULT_GIT_SYNC={}".format(env)
if os.path.isfile(config_path):
try:
cfg = json.load(open(config_path, encoding="utf-8"))
enabled = cfg.get("batch_result_git_sync", True)
return bool(enabled), "config batch_result_git_sync={}".format(enabled)
except Exception:
return True, "config 解析失败,按默认启用"
return True, "默认启用(无配置文件)"
def main():
parser = argparse.ArgumentParser(
description="批量压测结果 xlsx 实时同步至 GitCode 仓(Alice_zhou/issue,多机合并推送)")
parser.add_argument("--xlsx", default=DEFAULT_XLSX, help="本地 xlsx 路径,默认 out_res/批量压测结果.xlsx")
parser.add_argument("--repo_path", default=None,
help="仓内路径,默认取 config 的 batch_result_repo_path(缺省根目录 批量压测结果.xlsx)")
parser.add_argument("--config", default=DEFAULT_CONFIG, help="配置文件路径")
parser.add_argument("--retries", type=int, default=3, help="并发冲突重试次数,默认 3")
parser.add_argument("--message", default=None, help="commit message(默认自动生成,含主机名)")
parser.add_argument("--json", action="store_true", help="以 JSON 输出结果")
args = parser.parse_args()
ok, url, detail = sync_local_xlsx(args.xlsx, args.repo_path, args.config,
args.message, args.retries)
if args.json:
print(json.dumps({"ok": ok, "url": url, "detail": detail, "xlsx": args.xlsx},
ensure_ascii=False))
else:
if ok:
log("GIT_SYNC", "成功 → {} ({})".format(url, detail))
else:
log("GIT_SYNC", "失败: {}".format(detail))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())