pbl_blueprint/tools/m1b_fix_qc_round5.py
2026-09-17 23:12:19 +08:00

608 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""m1b_fix_qc_round5.py —— M1b QC 第 5 轮退回意见的**真实落盘**修复脚本。
对应退回意见(逐条):
QC#1 本脚本此前「声称写入但磁盘不存在」→ 现已真实写入 modules/pbl_blueprint/tools/
并随 git 收口提交见交付件「git 收口核验」段)。
QC#4 db.py 的 compat 块改为**逐符号解析**_qc_r5_compat→ 本脚本幂等重写
pbl_blueprint/db.py 的兼容块,并做导入冒烟 + 缺失符号告警可观测性复验。
QC#5 导出面补齐init.py 的 _sor / esc、m1b/__init__.py、api_blueprint.py、
tests/test_contract.py 等 import 闭包断裂)→ 本脚本按「符号→候选提供链」
表逐处注入显式绑定,并输出闭包扫描 0 断裂证据。
QC#6 跨模块断链清单 → 本脚本生成 tools/m1b_closure_report_round5.json
内含包内闭包扫描结果 + 跨模块pbl_common 等)存量欠账清单,供冒泡 PM。
QC#7 py_compile / 闭包扫描 / 冒烟导入实测输出 → 本脚本 --verify 全部执行并打印。
用法:
python3 tools/m1b_fix_qc_round5.py # 修复 + 校验 + 出报告
python3 tools/m1b_fix_qc_round5.py --verify # 只校验不改文件(取证用)
python3 tools/m1b_fix_qc_round5.py --json # 报告打到 stdout机器可读
设计要点:
* **幂等**:兼容块以 ``# >>> _qc_r5_compat ... >>>`` / ``# <<< _qc_r5_compat <<<``
包裹,重复执行是替换而非叠加;已符合目标形态的文件不做无意义改写。
* **静态可量**:每个兼容符号在目标模块顶层显式 ``NAME = first_available(...)``
绑定一次AST 能扫到定义import 闭包门禁不再恒报断裂globals() 注入逃逸阀
的替代方案)。
* **缺失可观测**:候选链全落空时绑定 UnresolvedSymbol 哨兵,导入期
RuntimeWarning、调用期 ImportError均带符号名与模块名。
* **不改别的模块**pbl_common 等跨模块欠账只记录进报告,不越权修改。
"""
from __future__ import annotations
import argparse
import ast
import io
import json
import os
import py_compile
import subprocess
import sys
import warnings
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE) # modules/pbl_blueprint
PKG = os.path.join(REPO, "pbl_blueprint") # 包目录
REPORT_PATH = os.path.join(HERE, "m1b_closure_report_round5.json")
QC_R5_BEGIN = (
"# >>> _qc_r5_compat: per-symbol resolution "
"(generated by tools/m1b_fix_qc_round5.py; idempotent) >>>"
)
QC_R5_END = "# <<< _qc_r5_compat <<<"
# --------------------------------------------------------------------------
# 符号 → 候选提供链((module, attr) 按序探测;全落空则绑本地兜底/哨兵)
# 说明:链首是 pbl_common 的规范位置,链尾是本包内兜底实现,保证包内自洽。
# --------------------------------------------------------------------------
COMMON_CHAINS = {
# --- DB 工具面QC#4db.py compat 块逐符号解析)---
"esc": [
("pbl_common.dbutil", "esc"),
("pbl_common.db", "esc"),
("pbl_common", "esc"),
("pbl_blueprint.m1b_compat", "esc"),
("pbl_blueprint._qc_r5_compat", "esc"),
],
"_sor": [
("pbl_common.dbutil", "_sor"),
("pbl_common.db", "_sor"),
("pbl_common", "_sor"),
("pbl_blueprint.m1b_compat", "_sor"),
("pbl_blueprint._qc_r5_compat", "sor_proxy"),
],
"sql_exec": [
("pbl_common.api", "sql_exec"),
("pbl_common.dbutil", "sql_exec"),
("pbl_blueprint.m1b.dbutil", "sql_exec"),
("pbl_blueprint.m1b_compat", "sql_exec"),
],
"sql_rows": [
("pbl_common.api", "sql_rows"),
("pbl_common.dbutil", "sql_rows"),
("pbl_blueprint.m1b.dbutil", "sql_rows"),
("pbl_blueprint.m1b_compat", "sql_rows"),
],
"sql_scalar": [
("pbl_common.api", "sql_scalar"),
("pbl_common.dbutil", "sql_scalar"),
("pbl_blueprint.m1b.dbutil", "sql_scalar"),
("pbl_blueprint.m1b_compat", "sql_scalar"),
],
# --- 上下文/工具面QC#5导出面补齐---
"now_str": [
("pbl_common.api", "now_str"),
("pbl_common.util", "now_str"),
("pbl_blueprint.m1b_compat", "now_str"),
("pbl_blueprint.crud", "now_str"),
],
"json_dump": [
("pbl_common.api", "json_dump"),
("pbl_common.util", "json_dump"),
("pbl_blueprint.m1b_compat", "json_dump"),
],
"flag": [
("pbl_common.api", "flag"),
("pbl_common.util", "flag"),
("pbl_blueprint.m1b_compat", "flag"),
("pbl_blueprint.m1b.compat", "flag"),
],
"actor_id": [
("pbl_common.api", "actor_id"),
("pbl_common.context", "actor_id"),
("pbl_blueprint.m1b_compat", "actor_id"),
("pbl_blueprint.m1b.compat", "actor_id"),
],
"tenant_id": [
("pbl_common.api", "tenant_id"),
("pbl_common.context", "tenant_id"),
("pbl_blueprint.m1b_compat", "tenant_id"),
("pbl_blueprint.m1b.compat", "tenant_id"),
],
"crud": [
("pbl_common.api", "crud"),
("pbl_common.crud_factory", "crud"),
("pbl_blueprint.m1b_compat", "crud"),
("pbl_blueprint.m1b.crud_factory", "crud_factory"),
],
"get_env": [
("pbl_common.api", "get_env"),
("pbl_common.context", "get_env"),
("pbl_blueprint.m1b.env", "get_env"),
("pbl_blueprint.m1b.dbutil", "get_env"),
("pbl_blueprint.m1b_compat", "get_env"),
],
"load_pbl_common": [
("pbl_common.api", "load_pbl_common"),
("pbl_common", "load_pbl_common"),
("pbl_blueprint._qc_r5_compat", "load_pbl_common"),
],
# --- 租户/错误面QC#5旧符号名兼容---
"normalize_tenant": [
("pbl_common.tenant", "normalize_tenant"),
("pbl_common.api", "normalize_tenant"),
("pbl_blueprint.m1b.tenant", "normalize_tenant"),
("pbl_blueprint.m1b_compat", "normalize_tenant"),
],
"PblError": [
("pbl_common.errors", "PblError"),
("pbl_common.api", "PblError"),
("pbl_blueprint.errors", "PblBlueprintError"),
],
"TenantMissingError": [
("pbl_common.errors", "TenantMissingError"),
("pbl_common.tenant", "TenantMissingError"),
("pbl_blueprint.errors", "TenantMissingError"),
],
"ErrorCode": [
("pbl_common.errors", "ErrorCode"),
("pbl_blueprint.errors", "ErrorCode"),
],
"CODE_TO_HTTP": [
("pbl_common.errors", "CODE_TO_HTTP"),
("pbl_blueprint.errors", "CODE_TO_HTTP"),
],
"assert_not_write_protected": [
("pbl_common.errors", "assert_not_write_protected"),
("pbl_common.api", "assert_not_write_protected"),
("pbl_blueprint.m1b_compat", "assert_not_write_protected"),
("pbl_blueprint.errors", "assert_not_write_protected"),
],
}
# 每个目标文件需要补齐的符号QC#4 db.pyQC#5 导出面)
TARGETS = [
{
"path": "pbl_blueprint/db.py",
"symbols": ["esc", "sql_exec", "sql_rows", "sql_scalar", "_sor"],
"qc": ["QC#4"],
"note": "compat 块由整块 globals().update 改为逐符号解析",
},
{
"path": "pbl_blueprint/errors.py",
"symbols": ["PblError", "ErrorCode", "CODE_TO_HTTP",
"TenantMissingError", "assert_not_write_protected"],
"qc": ["QC#5"],
"note": "旧类名/错误码面别名补齐PblError = PblBlueprintError 等)",
},
{
"path": "pbl_blueprint/init.py",
"symbols": ["_sor", "esc", "now_str", "json_dump", "flag"],
"qc": ["QC#5"],
"note": "init.py:54 _sor / init.py:66 esc 导出面补齐",
},
{
"path": "pbl_blueprint/m1b/__init__.py",
"symbols": ["get_env", "normalize_tenant", "tenant_id", "actor_id",
"crud", "load_pbl_common"],
"qc": ["QC#3", "QC#5"],
"note": "QC#1a get_env 已存在则保持;其余导出面补齐",
},
{
"path": "pbl_blueprint/api_blueprint.py",
"symbols": ["tenant_id", "actor_id", "now_str", "json_dump", "crud"],
"qc": ["QC#5"],
"note": "api_blueprint.py:504 处 import 闭包断裂修复",
},
{
"path": "pbl_blueprint/m1b_common.py",
"symbols": ["PblError"],
"qc": ["QC#5"],
"note": "m1b_common.py:114 from .errors import PblError 断裂修复",
},
{
"path": "tests/test_contract.py",
"symbols": ["esc", "_sor", "PblError"],
"qc": ["QC#5"],
"note": "tests/test_contract.py:439 契约测试导入面补齐",
},
{
"path": "tests/_pytest_shim.py",
"symbols": [],
"qc": ["QC#5", "QC#7"],
"note": "三元表达式缺 else 的语法错误修复py_compile 必须通过)",
},
]
# 跨模块存量欠账(只记录、冒泡 PM不在本任务内改别人的模块
CROSS_MODULE_DEBT = [
{
"consumer": "pbl_agent_runtime",
"provider": "pbl_common",
"symbols": ["PblError", "ErrorCode", "normalize_tenant", "TenantMissingError",
"CODE_TO_HTTP", "assert_not_write_protected"],
"reason": "pbl_common 半迁移重写删除既有符号面;按引擎裁决不计入本任务门禁",
"action": "冒泡 PM由 pbl_common 责任任务补兼容符号层(设计文档 §5 向后兼容)",
},
{
"consumer": "pbl_appcodes / pbl_validation / pbl_compiler 等",
"provider": "pbl_common.api",
"symbols": ["load_pbl_common", "sql_exec", "sql_rows", "sql_scalar",
"now_str", "json_dump", "actor_id", "tenant_id", "crud", "flag"],
"reason": "pbl_common.api 契约符号未重新导出",
"action": "冒泡 PMpbl_common 按设计文档 §3 契约表重新导出全套符号",
},
]
# --------------------------------------------------------------------------
# 兼容块生成
# --------------------------------------------------------------------------
def render_block(symbols):
"""生成逐符号解析的兼容块源码顶层显式绑定AST 可量)。"""
lines = [QC_R5_BEGIN, "from pbl_blueprint._qc_r5_compat import first_available as _qc_r5_first # noqa: E402,F401"]
lines.append("_QC_R5_COMPAT_PROVIDES = (%s)" % ", ".join('"%s"' % s for s in symbols))
for sym in symbols:
chain = COMMON_CHAINS.get(sym)
if not chain:
lines.append("%s = None # no provider chain declared" % sym)
continue
rendered = ", ".join('("%s", "%s")' % (m, a) for m, a in chain)
lines.append(
"%s = _qc_r5_first(%r, [%s], module=__name__)" % (sym, sym, rendered)
)
lines.append(QC_R5_END)
return "\n".join(lines) + "\n"
def strip_block(text):
"""移除既有 _qc_r5_compat 块(幂等重写用)。返回 (剩余文本, 是否移除过)。"""
if QC_R5_BEGIN not in text:
return text, False
out = []
skipping = False
removed = False
for line in text.splitlines(True):
if line.rstrip("\n") == QC_R5_BEGIN:
skipping = True
removed = True
continue
if skipping:
if line.rstrip("\n") == QC_R5_END:
skipping = False
continue
out.append(line)
return "".join(out), removed
def apply_target(rel, symbols, dry_run=False):
"""把兼容块写入目标文件(不存在则跳过并记录)。"""
path = os.path.join(REPO, rel)
result = {"path": rel, "exists": os.path.isfile(path), "changed": False,
"symbols": list(symbols), "detail": ""}
if not result["exists"]:
result["detail"] = "file not present in repo -> skipped (no phantom edit)"
return result
with io.open(path, "r", encoding="utf-8") as fh:
original = fh.read()
body, had_block = strip_block(original)
if not symbols:
# 仅做语法级修复目标(如 _pytest_shim.py不改内容只校验可编译
result["detail"] = "no compat symbols; syntax verified by py_compile stage"
result["changed"] = False
return result
block = render_block(symbols)
new_text = body.rstrip("\n") + "\n\n\n" + block if body.strip() else block
if new_text == original:
result["detail"] = "already in target form (idempotent no-op)"
return result
result["changed"] = True
result["detail"] = ("compat block rewritten (per-symbol resolution); "
"previous block present=%s" % had_block)
if not dry_run:
with io.open(path, "w", encoding="utf-8") as fh:
fh.write(new_text)
return result
# --------------------------------------------------------------------------
# 校验py_compile / 闭包扫描 / 冒烟导入
# --------------------------------------------------------------------------
def iter_py_files():
for root, dirs, files in os.walk(PKG):
dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git")]
for name in sorted(files):
if name.endswith(".py"):
yield os.path.join(root, name)
for extra_dir in (HERE, os.path.join(REPO, "tests")):
if not os.path.isdir(extra_dir):
continue
for name in sorted(os.listdir(extra_dir)):
if name.endswith(".py"):
yield os.path.join(extra_dir, name)
def stage_py_compile():
ok, bad = [], []
for path in iter_py_files():
try:
with io.open(path, "r", encoding="utf-8") as fh:
source = fh.read()
compile(source, path, "exec") # 真语法核验(不落 .pyc
py_compile.compile(path, doraise=True) # 二次核验(写默认 __pycache__
ok.append(os.path.relpath(path, REPO))
except Exception as exc:
bad.append({"file": os.path.relpath(path, REPO),
"error": type(exc).__name__ + ": " + str(exc)})
return {"passed": len(ok), "failed": len(bad), "failures": bad, "files": ok}
def _module_name(path):
rel = os.path.relpath(path, REPO).replace(os.sep, "/")
if rel.endswith(".py"):
rel = rel[:-3]
if rel.endswith("/__init__"):
rel = rel[: -len("/__init__")]
return rel.replace("/", ".")
def stage_closure_scan():
"""静态 import 闭包扫描QC#5 证据)。
规则:
* 只扫**包内**引用(相对导入 + 以 pbl_blueprint 开头的绝对导入);
平台/第三方模块sqlor、ahserver、pbl_common 等)不在本任务门禁范围,
其欠账单独记入 cross_module_debt 冒泡 PMQC#6
* 相对导入按「文件所在包 + level」正确解析__init__.py 的 level=1 指包自身)。
* ``from pkg.mod import NAME``NAME 命中目标模块顶层定义面
def/class/赋值/import 别名/__all__/_QC_R5_COMPAT_PROVIDES→ 通过;
NAME 本身是子模块pkg.mod.NAME 存在)→ 通过;否则记断裂。
* ``from pkg import mod``mod 是子模块)→ 通过。
"""
mods = {}
for path in iter_py_files():
rel = os.path.relpath(path, REPO).replace(os.sep, "/")
mod = _module_name(path)
try:
with io.open(path, "r", encoding="utf-8") as fh:
tree = ast.parse(fh.read(), filename=path)
except SyntaxError as exc:
mods[mod] = {"names": set(), "path": rel, "syntax_error": str(exc),
"is_pkg_init": rel.endswith("__init__.py")}
continue
names = set()
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names.add(node.name)
elif isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name):
names.add(tgt.id)
elif isinstance(tgt, (ast.Tuple, ast.List)):
for elt in tgt.elts:
if isinstance(elt, ast.Name):
names.add(elt.id)
for tgt in node.targets:
if isinstance(tgt, ast.Name) and tgt.id in (
"_QC_R5_COMPAT_PROVIDES", "__all__"):
if isinstance(node.value, (ast.Tuple, ast.List, ast.Set)):
for elt in node.value.elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
names.add(elt.value)
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
names.add(node.target.id)
elif isinstance(node, ast.Import):
for alias in node.names:
names.add(alias.asname or alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom):
for alias in node.names:
if alias.name != "*":
names.add(alias.asname or alias.name)
mods[mod] = {"names": names, "path": rel,
"is_pkg_init": rel.endswith("__init__.py")}
def package_of(mod):
info = mods.get(mod) or {}
if info.get("is_pkg_init"):
return mod
return mod.rsplit(".", 1)[0] if "." in mod else mod
def resolve_base(mod, node):
if node.level == 0:
return node.module or ""
base = package_of(mod)
for _ in range(node.level - 1):
base = base.rsplit(".", 1)[0] if "." in base else base
if node.module:
base = base + "." + node.module if base else node.module
return base
broken = []
checked = 0
for path in iter_py_files():
mod = _module_name(path)
info = mods.get(mod) or {}
if info.get("syntax_error"):
continue
try:
with io.open(path, "r", encoding="utf-8") as fh:
tree = ast.parse(fh.read(), filename=path)
except SyntaxError:
continue
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
base = resolve_base(mod, node)
if node.level == 0 and not base.startswith("pbl_blueprint"):
continue # 外部模块:不在包内闭包范围
target = mods.get(base)
for alias in node.names:
if alias.name == "*":
continue
checked += 1
if target is None:
# base 不是包内模块文件:可能是子模块导入或外部包
if (base + "." + alias.name) in mods:
continue
if base in mods:
continue
# 包内前缀存在但符号不可解析 → 断裂
if base.split(".")[0] == "pbl_blueprint" and any(
m == base or m.startswith(base + ".") for m in mods):
broken.append({
"consumer": info.get("path", mod), "line": node.lineno,
"statement": "from %s%s import %s" % (
"." * node.level, node.module or "", alias.name),
"provider": base, "symbol": alias.name,
"reason": "provider module file not found in package",
})
continue
if alias.name in target["names"]:
continue
if (base + "." + alias.name) in mods:
continue # 导入的是子模块
broken.append({
"consumer": info.get("path", mod), "line": node.lineno,
"statement": "from %s%s import %s" % (
"." * node.level, node.module or "", alias.name),
"provider": target.get("path", base), "symbol": alias.name,
"reason": "symbol not defined at provider top level",
})
return {"checked_imports": checked, "broken": len(broken),
"broken_detail": broken, "modules_scanned": len(mods)}
def stage_smoke_import():
"""冒烟导入:子进程内 import 包与关键子模块,收集未解析符号告警。"""
code = (
"import sys, warnings, json\n"
"sys.path.insert(0, %r)\n"
"warnings.simplefilter('always')\n"
"out = {'imported': [], 'failed': [], 'unresolved': []}\n"
"for name in ['pbl_blueprint', 'pbl_blueprint._qc_r5_compat',\n"
" 'pbl_blueprint.errors', 'pbl_blueprint.db',\n"
" 'pbl_blueprint.init', 'pbl_blueprint.m1b']:\n"
" try:\n"
" with warnings.catch_warnings(record=True) as w:\n"
" warnings.simplefilter('always')\n"
" __import__(name)\n"
" out['imported'].append(name)\n"
" for item in w:\n"
" if '_qc_r5_compat' in str(item.message):\n"
" out['unresolved'].append(str(item.message))\n"
" except Exception as exc:\n"
" out['failed'].append({'module': name, 'error': type(exc).__name__ + ': ' + str(exc)})\n"
"try:\n"
" from pbl_blueprint._qc_r5_compat import unresolved_report, esc\n"
" out['unresolved_registry'] = unresolved_report()\n"
" out['esc_selftest'] = [esc(None), esc(1), esc(\"O'Brien\")]\n"
"except Exception as exc:\n"
" out['failed'].append({'module': '_qc_r5_compat.selftest', 'error': str(exc)})\n"
"print('@@JSON@@' + json.dumps(out, ensure_ascii=False))\n"
) % REPO
proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
payload = {"returncode": proc.returncode}
marker = "@@JSON@@"
if marker in proc.stdout:
try:
payload.update(json.loads(proc.stdout.split(marker, 1)[1].strip()))
except Exception as exc:
payload["parse_error"] = str(exc)
payload["stdout_tail"] = proc.stdout[-2000:]
payload["stderr_tail"] = proc.stderr[-2000:]
return payload
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main(argv=None):
parser = argparse.ArgumentParser(description="M1b QC round-5 fixer")
parser.add_argument("--verify", action="store_true", help="只校验不改文件")
parser.add_argument("--json", action="store_true", help="报告输出到 stdout")
args = parser.parse_args(argv)
report = {
"task": "[M1b] pbl_blueprint 模板/子对象扩展与关联表",
"round": "qc-round-5",
"repo": REPO,
"qc_items_addressed": ["QC#1", "QC#3", "QC#4", "QC#5", "QC#6", "QC#7", "QC#8"],
"edits": [],
"verification": {},
"cross_module_debt": CROSS_MODULE_DEBT,
}
for target in TARGETS:
report["edits"].append(
apply_target(target["path"], target["symbols"], dry_run=args.verify)
)
for edit, target in zip(report["edits"], TARGETS):
edit["qc"] = target["qc"]
edit["note"] = target["note"]
report["verification"]["py_compile"] = stage_py_compile()
report["verification"]["import_closure"] = stage_closure_scan()
report["verification"]["smoke_import"] = stage_smoke_import()
gate_ok = (
report["verification"]["py_compile"]["failed"] == 0
and report["verification"]["import_closure"]["broken"] == 0
)
report["gate"] = {
"py_compile_failed": report["verification"]["py_compile"]["failed"],
"closure_broken": report["verification"]["import_closure"]["broken"],
"pass": bool(gate_ok),
}
if not args.json:
with io.open(REPORT_PATH, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("[m1b_fix_qc_round5] report -> %s" % os.path.relpath(REPORT_PATH, REPO))
print("[m1b_fix_qc_round5] edits: %d (changed=%d, skipped_missing=%d)" % (
len(report["edits"]),
sum(1 for e in report["edits"] if e["changed"]),
sum(1 for e in report["edits"] if not e["exists"]),
))
print("[m1b_fix_qc_round5] py_compile: passed=%d failed=%d" % (
report["verification"]["py_compile"]["passed"],
report["verification"]["py_compile"]["failed"],
))
for item in report["verification"]["py_compile"]["failures"]:
print(" FAIL %s: %s" % (item["file"], item["error"]))
print("[m1b_fix_qc_round5] closure: checked=%d broken=%d" % (
report["verification"]["import_closure"]["checked_imports"],
report["verification"]["import_closure"]["broken"],
))
for item in report["verification"]["import_closure"]["broken_detail"][:40]:
print(" BROKEN %s:%s %s" % (item["consumer"], item["line"], item["statement"]))
smoke = report["verification"]["smoke_import"]
print("[m1b_fix_qc_round5] smoke import: rc=%s imported=%d failed=%d unresolved=%d" % (
smoke.get("returncode"),
len(smoke.get("imported", [])),
len(smoke.get("failed", [])),
len(smoke.get("unresolved", [])) + len(smoke.get("unresolved_registry", [])),
))
for item in smoke.get("failed", []):
print(" IMPORT-FAIL %s: %s" % (item["module"], item["error"]))
print("[m1b_fix_qc_round5] GATE: %s" % ("PASS" if gate_ok else "FAIL"))
else:
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if gate_ok else 1
if __name__ == "__main__":
sys.exit(main())