diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..43cd0454 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: CI + +# PR 门禁。本仓此前没有任何 workflow,CI 只有一个早已停用的 .travis.yml +# (且只测 3.5/3.6/3.7),等于生成代码从来没有闸门——本文件就是补这一层。 +# +# 门禁只保两件事(详见 Makefile 的 ci-syntax): +# G1 生成的代码能被解释器编译 —— 语法错误、非法标识符即失败 +# G2 生成的代码没有重复声明 —— python 的重复 def/class 不是语法错误, +# 后者静默覆盖前者,compileall 一律放行,必须另起 ast 扫描 +# 代码风格不进门禁:风格不影响 SDK 能否使用,且格式化已由生成侧的 make fmt 负责, +# 见 Makefile 里 ci-syntax 上方关于 black 的说明。 +# +# 分支保护只需把 `ci-gate` 配成 required check,其余 job 增删都不影响它。 +# +# 版本取 3.12 而不是仓库声明的下界:setup.py 写着 python_requires=">=3.5", +# 但那早就不成立(tests 里曾用 3.10 已移除的 collections.Callable, +# Travis 只测到 3.7 所以从未暴露)。老解释器的兼容性不进 PR 门禁—— +# 那是「声明还成不成立」的问题,门禁要回答的是"这次改动有没有把代码写坏"。 + +on: + pull_request: + branches: [master] + push: + branches: [master] + +permissions: + contents: read + +jobs: + ci-syntax: + name: ci-syntax + runs-on: ubuntu-latest + # 两条闸全量扫描整棵 ucloud/,但只对**本轮变更的文件**阻断,存量命中只告警。 + # 原因:uphost 那处重复方法是躺在 master 上的存量缺陷(已删除 API 的旧快照被 + # publish 集合留了下来),整棵树一律阻断就等于发**任何一个**产品的 codegen PR + # 都会被一个与本次发布毫无关系的问题判红,mergeable_state 变 unstable, + # 自动合并永久失效——那正是这套门禁要解决的问题本身。 + # 同源决策见 ucloud-sdk-release/scripts/python-fatal-check.sh(2026-07-31, + # 已被 pipeline 2507836 实证:那次发的是 UHost+UAI-Modelverse,根本没重新 + # 生成 uphost,那处重复照样被扫了出来)。 + # 变更集怎么算、拿不到时怎么退回,见 scripts/ci-syntax.sh 文件头。 + env: + # 严格集的基点。PR 事件取目标分支侧的 base.sha(配三点式 diff 正好等于本 PR + # 引入的改动),push 事件取推送前的 before。两者都拿不到或解析不出时, + # 脚本自行退回整棵树严格判定并打印原因,不会静默放行。 + CI_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + steps: + - uses: actions/checkout@v7 + with: + # 默认 fetch-depth: 1 只拉一个 commit,base sha 不在本地对象库里, + # git diff 必然解析不出 → 每次都退回整棵树严格判定,分级就白做了。 + # 只有本 job 需要历史,lint / test 保持浅克隆。 + fetch-depth: 0 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + # ci-syntax 本身只用 stdlib(compileall + ast),装 .[ci] 是为了让三个 job + # 的环境一致,省掉「哪个 job 该装什么」的分叉。 + - run: pip install -e '.[ci]' + - run: make ci-syntax + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: pip install -e '.[ci]' + # flake8 显式 --exclude 掉了 ucloud/services,也就是这一层只管手写代码; + # 生成代码的把关全在 ci-syntax,两者不重叠。 + - run: make lint + + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: pip install -e '.[ci]' + - run: make test-cov + # 上传失败不能影响门禁:codecov 是外部服务,它抖动跟代码质量无关。 + # 故 continue-on-error,且这个 step 所在的 test job 之外不另设 codecov job。 + - name: Upload coverage + continue-on-error: true + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + + ci-gate: + name: ci-gate + needs: [ci-syntax, lint, test] + if: always() + runs-on: ubuntu-latest + steps: + - name: Aggregate + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" \ + || "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "ci-gate: 有 job 失败或被取消" + exit 1 + fi + echo "ci-gate: 全部通过" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c90c76d6..00000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -# Config file for automatic testing at travis-ci.org - -language: python -python: - - 3.7 - - 3.6 - - 3.5 -dist: xenial -sudo: true - -# command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors -install: pip install -e .[ci] - -# command to run tests, e.g. python setup.py test -script: - - make lint - - make test-cov -# - make release-check - -after_success: - - bash <(curl -s https://codecov.io/bash) diff --git a/Makefile b/Makefile index acd2cfa2..01c21f1f 100644 --- a/Makefile +++ b/Makefile @@ -34,8 +34,11 @@ help: test: clean pytest +# --cov-report 两项显式写全:只要指定了 --cov-report,pytest-cov 就不再自动出 term 报告。 +# xml 是给 codecov-action 用的——它只上传现成的报告文件,不会自己去生成 +# (Travis 时代那个 codecov bash uploader 会顺手跑 `coverage xml`,action 不会)。 test-cov: clean - pytest --cov=ucloud/core tests + pytest --cov=ucloud/core tests --cov-report=term --cov-report=xml test-cov-html: pytest --cov=ucloud/core tests --cov-report html @@ -47,6 +50,41 @@ test-acc: clean test-all: clean tox +# CI 门禁:生成代码必须能被解释器接受、且无重复声明。门禁只管「代码能不能用」, +# 不管风格——语法错误、非法标识符、重复声明是缺陷,缩进和空行不是。 +# G1 `compileall` 覆盖 ucloud/ 下全部生成代码(现有 lint 显式 --exclude 掉了 +# ucloud/services,也就是生成代码一直没人查)。真缺陷它抓得住,实测: +# `def uai-modelverse(self):`(连字符产品名回填成非法标识符,真实事故形态) +# → exit 1 SyntaxError;而纯格式问题 `def foo( a,b ):` → exit 0。 +# 这正是想要的分界线。 +# G2 python 里重复的 class/def/import 不是语法错误——后者静默覆盖前者, +# compileall 一律 exit 0,只能另起 ast 扫描补上。 +# 真实案例:uphost 的 get_phost_disk_upgrade_price 被生成了两遍, +# 后者是已删除 API 的旧快照,把唯一在用的那个覆盖掉了。 +# +# 两条闸都**全量扫描、只对本轮变更的文件阻断**,实现收进 scripts/ci-syntax.sh: +# G2 那个真实案例就躺在上游 master 上,整棵树一律阻断的话,发**任何一个**产品 +# (UDisk、UNet……)的 codegen PR 都会被这处与本次发布毫无关系的存量问题判红, +# mergeable_state 变 unstable,自动合并永久失效——那正是这套门禁要解决的问题本身。 +# 同源决策见 ucloud-sdk-release/scripts/python-fatal-check.sh(2026-07-31,已被 +# pipeline 2507836 实证)。变更集怎么算、拿不到时怎么 fail-closed 退回整棵树, +# 见 scripts/ci-syntax.sh 文件头。本地不设 CI_BASE_SHA,看到的就是整棵树严格判定。 +# +# 这里**故意不放** `black --check`,与 go 的 ci-syntax 带 gofmtcheck.sh 不对称, +# 不是漏了: +# · gofmt 的输出多年稳定,版本又由 workflow 里的 setup-go 钉住,两端天然对齐; +# black 每年改一次稳定风格——实测本仓 392 个文件在 24.8.0 / 24.10.0 下全过, +# 到 25.1.0 起有 211 个被判需重排(改了 docstring 首尾空白的处理)。 +# · 而生成侧 ucloud-sdk-release 的 python:format job 是 `pip install -U black` +# 之后跑 `make fmt`,永远用当天最新版格式化并提交。校验端无论钉哪个版本, +# 都会在 black 发新版后与生成侧偏斜,把每个 codegen PR 恒判为红—— +# 那恰好是自动合并要解决的问题本身。 +# · 格式化是生成侧 `make fmt` 的职责,生成时已经跑过,门禁再 --check 一遍 +# 不增加保障,只增加版本偏斜的风险。 +.PHONY: ci-syntax +ci-syntax: + @bash scripts/ci-syntax.sh + lint: @flake8 --exclude=ucloud/services ucloud --ignore=E501,F401 diff --git a/scripts/ci-syntax.sh b/scripts/ci-syntax.sh new file mode 100644 index 00000000..b084e8cc --- /dev/null +++ b/scripts/ci-syntax.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# CI 语法门禁:G1 编译闸(compileall)+ G2 重复闸(dup-check.py)。由 `make ci-syntax` 调用。 +# +# ── 为什么「全量扫描」和「严格判定」是两个范围 ──────────────────────────────── +# 仓里躺着存量缺陷:ucloud/services/uphost/client.py 有一处重复方法 +# get_phost_disk_upgrade_price——已删除 API 的旧快照被 publish 集合(追加式存储)留了 +# 下来,python codegen 直读该集合就把幽灵一并生成。它改 SDK 仓库无效,根治在 +# ucloud-model 侧补「编辑态已不存在则丢弃」的过滤,属独立任务。 +# +# 若整棵树一律阻断,发**任何一个**产品(UDisk、UNet……)的 codegen PR 都会被这处与本次 +# 发布毫无关系的存量问题判红,mergeable_state 随之变 unstable,自动合并永久失效—— +# 那恰好是这套门禁要解决的问题本身。 +# +# 后果 2026-07-31 已被实证并明确否决:pipeline 2507836 发的是 UHost+UAI-Modelverse、 +# 根本没重新生成 uphost,那处重复照样被扫了出来,证明来源是存量代码而非本轮产物。 +# 当时的处置是「判定边界取本轮变更文件,存量只告警」,落在 +# ucloud-sdk-release/scripts/python-fatal-check.sh 的文件头。本脚本把同一原则落到 +# GitHub Actions 上,两端同源。 +# +# 存量仍然**全量扫描**:不阻断不等于不检查。债务要保持可见、可度量,否则收窄判定范围 +# 就退化成了掩盖问题。 +# +# ── 严格集怎么算 ──────────────────────────────────────────────────────────── +# 取环境变量 CI_BASE_SHA(workflow 里 PR 事件给 base.sha、push 事件给 before): +# · 能解析成 commit → 严格集 = `git diff --name-only $CI_BASE_SHA...HEAD` 结果中 +# ucloud/ 下的 .py 文件。三点式取的是 merge-base 到 HEAD,正好等于本 PR 引入的改动; +# --diff-filter=d 排除删除项,删掉的文件没法编译。 +# · 未设置 / 解析不出(force push 覆盖了原提交、新分支的全零 sha、clone 深度不够、 +# 两端无共同祖先)→ 退回整棵树严格判定,并打印退回原因。 +# fail-closed:拿不准就严格,绝不静默放行。本地不设该变量看到的就是整棵树严格判定, +# 与 CI 的退回路径完全一致,不引入「本地绿 / CI 红」的分叉。 + +# 不用 set -e:本脚本要接住 compileall / dup-check 的非零退出码自行分级判定。 +set -uo pipefail + +ROOT_DIR="ucloud" +MAX_LIST_ECHO=30 # 全量重生成会变更数百个文件,日志里全打出来只是噪声 + +if [ ! -d "$ROOT_DIR" ]; then + echo "FATAL: 未找到 ${ROOT_DIR}/,请在仓库根目录执行(当前目录:$(pwd))。" + exit 1 +fi + +# ── 第一步:确定严格集 ─────────────────────────────────────────────────────── +SUBSET_MODE=0 # 1 = 严格集是子集;0 = 严格集是整棵树 +FALLBACK_REASON="" # SUBSET_MODE=0 时说明为什么退回 +STRICT_FILES=() + +if [ -z "${CI_BASE_SHA:-}" ]; then + FALLBACK_REASON="未设置 CI_BASE_SHA(本地直接执行 make ci-syntax 的常态)" +elif ! git rev-parse --verify --quiet "${CI_BASE_SHA}^{commit}" >/dev/null 2>&1; then + FALLBACK_REASON="CI_BASE_SHA=${CI_BASE_SHA} 解析不出 commit(force push 覆盖了原提交 / 新分支的全零 sha / checkout 没设 fetch-depth: 0 / 不在 git 仓库里)" +elif ! CHANGED="$(git diff --name-only --diff-filter=d "${CI_BASE_SHA}...HEAD" -- "$ROOT_DIR" 2>&1)"; then + FALLBACK_REASON="git diff ${CI_BASE_SHA}...HEAD 执行失败(两端无共同祖先时会这样):${CHANGED}" +else + SUBSET_MODE=1 + while IFS= read -r f; do + # 只收 .py;-f 兜底防止 rename/filter 之外的意外让 compileall 拿到不存在的路径 + case "$f" in + *.py) [ -f "$f" ] && STRICT_FILES+=("$f") ;; + esac + done <<<"$CHANGED" +fi + +echo "════ ci-syntax ════" +if [ "$SUBSET_MODE" -eq 1 ]; then + echo "严格判定范围:本轮变更的 ${#STRICT_FILES[@]} 个文件(${CI_BASE_SHA}...HEAD 下 ${ROOT_DIR}/ 的 .py)" + echo "全量扫描范围:整棵 ${ROOT_DIR}/(存量命中只告警,不计入退出码)" + if [ "${#STRICT_FILES[@]}" -gt 0 ] && [ "${#STRICT_FILES[@]}" -le "$MAX_LIST_ECHO" ]; then + printf ' %s\n' "${STRICT_FILES[@]}" + fi +else + echo "严格判定范围:整棵 ${ROOT_DIR}/" + echo "退回原因:${FALLBACK_REASON}" +fi +echo + +# ── 第二步:G1 编译闸 ──────────────────────────────────────────────────────── +# compileall 覆盖 ucloud/ 下全部生成代码(lint 显式 --exclude 掉了 ucloud/services, +# 也就是生成代码一直没人查)。真缺陷它抓得住,实测:`def uai-modelverse(self):` +# (连字符产品名回填成非法标识符,真实事故形态)→ exit 1 SyntaxError; +# 而纯格式问题 `def foo( a,b ):` → exit 0。这正是想要的分界线。 +echo "─── G1 编译闸 [全量扫描] python -m compileall ${ROOT_DIR} ───" +python -m compileall -q "$ROOT_DIR" +g1_full=$? + +if [ "$SUBSET_MODE" -eq 0 ]; then + g1_gate=$g1_full + if [ "$g1_full" -eq 0 ]; then + echo "G1 通过:${ROOT_DIR}/ 全部可编译" + else + echo "G1 失败:整棵树严格判定,上方 *** Error compiling 即阻断项" + fi +else + if [ "$g1_full" -eq 0 ]; then + echo "G1 全量扫描:${ROOT_DIR}/ 全部可编译" + else + echo "⚠️ G1 全量扫描命中(上方 *** Error compiling)。落在本轮变更集之外的命中只告警," + echo " 不计入退出码;若下面的严格判定同样命中,才会真阻断。" + fi + if [ "${#STRICT_FILES[@]}" -eq 0 ]; then + echo "─── G1 编译闸 [严格判定] 本轮 ${ROOT_DIR}/ 下无变更的 .py,跳过 ───" + g1_gate=0 + else + echo "─── G1 编译闸 [严格判定] 本轮变更的 ${#STRICT_FILES[@]} 个文件 ───" + python -m compileall -q "${STRICT_FILES[@]}" + g1_gate=$? + [ "$g1_gate" -eq 0 ] && echo "G1 通过:本轮变更文件全部可编译" + fi +fi +echo + +# ── 第三步:G2 重复闸 ──────────────────────────────────────────────────────── +# python 里重复的 class/def/import 不是语法错误——后者静默覆盖前者,compileall 一律 +# exit 0,只能另起 ast 扫描补上。dup-check.py 一趟扫完整棵树,命中按是否落在严格集里 +# 分成阻断 / 告警两类,故这里不需要像 G1 那样跑两遍。 +echo "─── G2 重复闸 scripts/dup-check.py ───" +if [ "$SUBSET_MODE" -eq 0 ]; then + python scripts/dup-check.py "$ROOT_DIR" + g2_gate=$? +else + STRICT_LIST_FILE="$(mktemp)" + trap 'rm -f "$STRICT_LIST_FILE"' EXIT + if [ "${#STRICT_FILES[@]}" -gt 0 ]; then + printf '%s\n' "${STRICT_FILES[@]}" >"$STRICT_LIST_FILE" + else + : >"$STRICT_LIST_FILE" + fi + python scripts/dup-check.py --strict-list "$STRICT_LIST_FILE" "$ROOT_DIR" + g2_gate=$? +fi +echo + +# ── 汇总 ──────────────────────────────────────────────────────────────────── +if [ "$g1_gate" -ne 0 ] || [ "$g2_gate" -ne 0 ]; then + echo "ci-syntax 失败:G1=${g1_gate} G2=${g2_gate}" + exit 1 +fi +echo "ci-syntax 通过:G1/G2 在严格判定范围内均无命中" diff --git a/scripts/dup-check.py b/scripts/dup-check.py new file mode 100644 index 00000000..ff9d7c70 --- /dev/null +++ b/scripts/dup-check.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""G2 重复闸 —— python 生成代码重复声明检测。 + +背景:`python -m compileall` 对同一模块内重复的 class/def 一律 exit 0 +(Python 语义上是后者覆盖前者,不是语法错误),故编译器闸抓不住这类缺陷。 +本脚本用 stdlib 的 ast 补上,零额外依赖。 + +检测三类: + 1. 模块顶层重复的 class / def 名 + 2. 类体内重复的方法名 + 3. 重复的 import 别名(from x import Y 出现多次同名 Y) + +用法: + dup-check.py <目录|文件> [...] + 全部命中都阻断。本地开发和 CI 的退回路径走这条。 + dup-check.py --strict-list <清单文件> <目录|文件> [...] + **仍然全量扫描**,但只有清单内文件的命中才阻断,其余降为告警。 + 清单文件每行一个路径(相对当前目录或绝对均可),空行忽略。 + +为什么要分级:仓里有存量的幽灵重复(uphost 的 get_phost_disk_upgrade_price), +整棵树一律阻断会让**任何**产品的 codegen PR 被这处无关问题判红,自动合并永久失效。 +完整推理与判定边界见 scripts/ci-syntax.sh 文件头。 + +退出码:0 = 阻断级命中为 0;1 = 有阻断级命中,或参数/路径有误 +""" +import argparse +import pathlib +import ast +import sys + + +def dup_names(nodes, kinds): + seen = {} + for n in nodes: + if isinstance(n, kinds): + seen.setdefault(n.name, []).append(n.lineno) + return {k: v for k, v in seen.items() if len(v) > 1} + + +DEF_KINDS = (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) +FUNC_KINDS = (ast.FunctionDef, ast.AsyncFunctionDef) + + +def check(path): + findings = [] + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except SyntaxError as exc: + # 语法错误归 G1 编译器闸管,这里只记录不重复判定 + findings.append("%s:%s 语法错误(应由 G1 拦截):%s" % (path, exc.lineno, exc.msg)) + return findings + + for name, lines in dup_names(tree.body, DEF_KINDS).items(): + findings.append("%s:%s 顶层重复声明 '%s'" % (path, ",".join(map(str, lines)), name)) + + for node in tree.body: + if isinstance(node, ast.ClassDef): + for name, lines in dup_names(node.body, FUNC_KINDS).items(): + findings.append( + "%s:%s 类 '%s' 内重复方法 '%s'" + % (path, ",".join(map(str, lines)), node.name, name) + ) + + # 只看**模块顶层**的 import。嵌在 if / try 里的是条件导入, + # 同名出现在不同分支是标准的版本兼容写法,不是重复。 + # 实例:ucloud/core/utils/compat.py 用 `if PY3: from collections.abc import Callable + # else: from collections import Callable` —— 用 ast.walk 会误判为重复。 + aliases = {} + for node in tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + for alias in node.names: + key = alias.asname or alias.name + aliases.setdefault(key, []).append(node.lineno) + for name, lines in aliases.items(): + if len(lines) > 1: + findings.append("%s:%s 重复导入 '%s'" % (path, ",".join(map(str, lines)), name)) + + return findings + + +def norm(path): + """统一成绝对路径再比对:清单来自 git(仓库根相对路径),扫描结果来自 rglob + (命令行给的 root 相对路径),两边的字面量对不上,只有归一化后才可靠。""" + return str(pathlib.Path(path).resolve()) + + +def iter_py_files(root): + """root 可以是目录(递归取 *.py)也可以是单个 .py 文件——后者是为了让调用方 + 能直接传变更文件清单,不必先反推目录。""" + p = pathlib.Path(root) + if p.is_dir(): + return sorted(p.rglob("*.py")) + if p.is_file(): + return [p] if p.suffix == ".py" else [] + return None # 不存在:由调用方 fail-closed 处理,不能当成「扫过且没问题」 + + +def load_strict_set(list_path): + p = pathlib.Path(list_path) + if not p.is_file(): + return None + return {norm(line) for line in p.read_text(encoding="utf-8").splitlines() if line.strip()} + + +def main(argv): + parser = argparse.ArgumentParser( + description="python 生成代码重复声明检测(G2 重复闸)", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--strict-list", + metavar="清单文件", + help="每行一个路径;只有清单内文件的命中才阻断,其余降为告警。不给则全部阻断。", + ) + parser.add_argument("roots", nargs="*", default=None, help="要扫描的目录或 .py 文件") + args = parser.parse_args(argv[1:]) + + roots = args.roots or ["ucloud"] + + strict_set = None + if args.strict_list is not None: + strict_set = load_strict_set(args.strict_list) + if strict_set is None: + # 读不到清单就当「什么都不阻断」是静默放行,与 fail-closed 相悖,直接报错。 + print("G2 重复闸:--strict-list 指向的文件不存在:%s" % args.strict_list) + return 1 + + strict, advisory = [], [] + scanned = 0 + for root in roots: + paths = iter_py_files(root) + if paths is None: + print("G2 重复闸:路径不存在,无法扫描:%s" % root) + return 1 + for path in paths: + scanned += 1 + findings = check(path) + if not findings: + continue + if strict_set is None or norm(path) in strict_set: + strict.extend(findings) + else: + advisory.extend(findings) + + if advisory: + print("G2 重复闸 [告警]:存量文件命中 %d 处 —— 不阻断,只保持可见" % len(advisory)) + for line in advisory: + print(" " + line) + print(" (这些文件不在本轮变更集内,命中来自既有代码;判定边界见 scripts/ci-syntax.sh 文件头)") + + if strict: + print("G2 重复闸 [阻断] 失败:命中 %d 处(共扫描 %d 个文件)" % (len(strict), scanned)) + for line in strict: + print(" " + line) + return 1 + + if strict_set is None: + print("G2 重复闸通过:%d 个文件无重复声明" % scanned) + else: + print( + "G2 重复闸通过:扫描 %d 个文件;本轮变更文件 0 处命中,存量告警 %d 处" + % (scanned, len(advisory)) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/test_unit/test_core/test_client.py b/tests/test_unit/test_core/test_client.py index f9c1c03c..5797f31a 100644 --- a/tests/test_unit/test_core/test_client.py +++ b/tests/test_unit/test_core/test_client.py @@ -3,7 +3,6 @@ import pytest import logging -import collections import requests_mock from ucloud.client import Client @@ -102,5 +101,5 @@ def test_client_try_import(client): continue client_factory = getattr(client, name) - if isinstance(client_factory, collections.Callable): + if callable(client_factory): print(client_factory())