From 351f0936d61933aa1de0ce7cd97fcb1259092e15 Mon Sep 17 00:00:00 2001 From: Episkey Date: Tue, 18 Aug 2026 03:17:04 -0700 Subject: [PATCH 1/3] fix(test): replace collections.Callable, removed in Python 3.10 test_client_try_import used `isinstance(x, collections.Callable)`. `collections.Callable` has been an alias for `collections.abc.Callable` since 3.3 and was removed in 3.10, so the test raises: AttributeError: module 'collections' has no attribute 'Callable' The suite therefore only passes on Python <= 3.9. .travis.yml tests 3.5/3.6/3.7, which is why this was never noticed -- and Travis has not run on this repo for a long time either. Uses the builtin `callable()` instead of `collections.abc.Callable`: same semantics (collections.abc.Callable.__subclasshook__ just checks for __call__), and it drops the now-unused `collections` import rather than keeping one around for a single predicate. Verified on Python 3.11.9: before 1 failed, 47 passed, 37 skipped after 48 passed, 37 skipped --- tests/test_unit/test_core/test_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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()) From b223d06300a2ebc97de6d7dde5679bdb65e83944 Mon Sep 17 00:00:00 2001 From: Episkey Date: Tue, 18 Aug 2026 04:27:00 -0700 Subject: [PATCH 2/3] =?UTF-8?q?ci:=20=E4=BB=8E=E9=9B=B6=E5=BB=BA=20GitHub?= =?UTF-8?q?=20Actions=20=E9=97=A8=E7=A6=81=EF=BC=8C=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=97=A9=E5=B7=B2=E5=81=9C=E7=94=A8=E7=9A=84=20Travis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本仓此前 .github/ 下只有 PR 模板,唯一的 CI 是 .travis.yml(travis-ci.org 已下线, 且只测 3.5/3.6/3.7)——等于 ucloud/services 下全部生成代码从来没有过闸门。 codegen PR 要能自动合并,前提是先有一个「会红」的门禁;但这个门禁必须只对本轮 产物负责,否则它自己就成了自动合并的阻塞源。 - 新增 .github/workflows/ci.yml:触发限 pull_request/push 到 master(不用裸 [push, pull_request]),显式 permissions: contents: read;ci-syntax / lint / test-cov 三个 job 汇总成恒名的 ci-gate(needs + if: always(),任一 failure/cancelled 即 exit 1),分支保护只需把 ci-gate 配成 required check。 codecov 上传是 test job 内的 step 且 continue-on-error,不进 ci-gate 的 needs ——外部服务抖动不该拦下合并。所有语言命令都经 Makefile target,yml 里不写裸命令。 - 新增 scripts/ci-syntax.sh,Makefile 的 ci-syntax 改为调它。两条闸都只用 stdlib: G1 `compileall -q ucloud` 覆盖全部生成代码(现有 lint 显式 --exclude=ucloud/services,恰好把生成代码排除在外,这层一直是空的)。 G2 `scripts/dup-check.py` 用 ast 补编译器抓不住的重复声明——python 里重复的 def/class 不是语法错误,后者静默覆盖前者,compileall 一律 exit 0。 - 判定边界:**全量扫描整棵 ucloud/,但只对本轮变更的文件阻断**,其余命中打印成 告警、不计入退出码。原因是 G2 在本仓存量就有一处命中——uphost 的 get_phost_disk_upgrade_price 被生成了两遍,后者是已删除 API 的旧快照(publish 集合追加式存储,保留了 API 改名前的形态),根因在数据侧、改 SDK 仓库无效。 整棵树一律阻断的话,发**任何一个**产品(UDisk、UNet……)的 codegen PR 都会被 这处与本次发布毫无关系的问题判红,mergeable_state 变 unstable,自动合并永久 失效——那正是这套门禁要解决的问题本身。 同源决策见 ucloud-sdk-release/scripts/python-fatal-check.sh(2026-07-31), 已被 pipeline 2507836 实证:那次发的是 UHost+UAI-Modelverse,根本没重新生成 uphost,那处重复照样被扫了出来。 变更集取环境变量 CI_BASE_SHA:`git diff --name-only $CI_BASE_SHA...HEAD` 里 ucloud/ 下的 .py(三点式取 merge-base→HEAD,正好等于本 PR 引入的改动; --diff-filter=d 排除删除项)。yml 里 PR 事件给 base.sha、push 事件给 before, checkout 配 fetch-depth: 0(浅克隆下 base sha 不在本地对象库,必然解析不出)。 未设置或解析不出(force push、全零 sha、无共同祖先)→ fail-closed 退回整棵树 严格判定并打印退回原因。本地不设该变量看到的就是整棵树严格判定,与退回路径 一致,不存在「本地绿 / CI 红」的分叉。 dup-check.py 因此加了 --strict-list(仍全量扫描,只有清单内文件的命中才阻断), 原有的 `dup-check.py <目录>...` 用法不变,本地与退回路径都走它。 - ci-syntax 里**不放** `black --check`,与 go 的 ci-syntax 带 gofmtcheck.sh 不对称, 是有意为之:生成侧 ucloud-sdk-release 的 python:format job 用 `pip install -U black` + `make fmt`,永远拿当天最新版格式化后提交;而 black 每年改一次稳定风格(实测本仓 392 个文件在 24.8.0/24.10.0 下全过,25.1.0 起有 211 个被判需重排)。校验端无论钉哪个版本,迟早与生成侧偏斜、把每个 codegen PR 恒判为红——同样是自动合并要解决的问题本身。格式化归生成侧,门禁只管代码能不能 用:非法标识符这类真缺陷 compileall 已经拦得住(实测 `def uai-modelverse(self):` → exit 1,而纯格式的 `def foo( a,b ):` → exit 0)。理由写在 Makefile 注释里。 - Makefile test-cov 补 --cov-report=term --cov-report=xml:codecov-action 只上传 现成的报告文件,不像 Travis 时代的 codecov bash uploader 会顺手跑 coverage xml; 不出 xml 的话 README 上那个 Codecov 徽章就再也不会更新。 - 新增 .github/workflows/compat.yml:只挂 schedule + workflow_dispatch,不挂 pull_request/push——它问的是「SDK 今天还能跑在哪些 python 上」,与单个 PR 改了 什么无关,挂到 PR 上只会在每个 codegen PR 上多出一排无关 check、干扰门禁判读。 矩阵 3.8–3.14、fail-fast: false。下界 3.8 是实测结论,不是 setup.py 声明的 3.5: actions/python-versions 对 3.7 最后只出到 3.7.17 且只有 linux-20.04/22.04 产物, 而 ubuntu-latest 现指向 Ubuntu 24.04,setup-python 根本装不出 3.7;3.8.18 有 linux-24.04 产物,且本地 3.8.20 上 pip install -e .[ci]、make lint、make test-cov 均 exit 0(48 passed / 37 skipped)。 - 删 .travis.yml:服务已下线,留着只会让人误以为还有 CI 在跑。 setup.py 不动:CI 不跑 black,[ci] 就不需要它;在 [ci] 里钉 black 版本反而会把 生成侧 python:format 那步的 `pip install -e ".[ci]"` 降级,干扰生成端。 本地实跑(macOS bash 3.2 + python 3.11.9): 不设 CI_BASE_SHA → exit 1,G2 报出 uphost 那处存量 CI_BASE_SHA=父提交、只改干净的 udisk → exit 0,uphost 那处降为告警仍打印 同上但 udisk 注入语法错 / 重复方法 → exit 1(对本轮变更文件仍然阻断) CI_BASE_SHA 给不存在的 sha / 全零 sha → 退回整棵树严格判定,exit 1,打印退回原因 make lint / make test-cov → exit 0,未受影响 --- .github/workflows/ci.yml | 106 ++++++++++++++++++++++ .github/workflows/compat.yml | 52 +++++++++++ .travis.yml | 21 ----- Makefile | 40 ++++++++- scripts/ci-syntax.sh | 139 +++++++++++++++++++++++++++++ scripts/dup-check.py | 168 +++++++++++++++++++++++++++++++++++ 6 files changed, 504 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/compat.yml delete mode 100644 .travis.yml create mode 100644 scripts/ci-syntax.sh create mode 100644 scripts/dup-check.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c06d862d --- /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 所以从未暴露)。老解释器的兼容性交给 compat.yml 定期跑, +# 不进 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/.github/workflows/compat.yml b/.github/workflows/compat.yml new file mode 100644 index 00000000..b5aad0e0 --- /dev/null +++ b/.github/workflows/compat.yml @@ -0,0 +1,52 @@ +name: compat + +# 解释器兼容矩阵。只挂 schedule + workflow_dispatch,**不挂 pull_request / push**: +# 它要回答的是"这个 SDK 今天还能跑在哪些 python 上",与单个 PR 改了什么无关; +# 挂到 PR 上只会在每个 codegen PR 上多出一排与本次改动无关的 check, +# 干扰门禁判读(合并门禁只认 ci.yml 里那个 ci-gate)。 +# +# 矩阵下界 3.8 —— **实测结论,不等于 setup.py 声明的下界**。 +# setup.py 写 python_requires=">=3.5"、classifiers 还列着 3.5/3.6/3.7, +# 但那三个版本今天在 CI 上根本装不出解释器,写进矩阵只会恒红: +# · actions/python-versions 对 3.7 最后只出到 3.7.17,产物仅 linux-20.04 / 22.04, +# 没有 24.04;而 actions/runner-images 的 README 里 ubuntu-latest 现指向 +# Ubuntu 24.04(26.04 还是 preview),setup-python 会直接报 +# "The version '3.7' with architecture 'x64' was not found for Ubuntu 24.04"。 +# · 3.8 则有 python-3.8.18-linux-24.04-x64 产物,装得出来。 +# · 本地实测 python 3.8.20:`pip install -e .[ci]` / `make lint` / `make test-cov` +# 均 exit 0(48 passed、37 skipped),依赖链能完整解出(sphinx 7.1.2、 +# pytest 8.3.5、flake8 7.1.2)。 +# 上界 3.14 —— actions/python-versions 当前的最新稳定版(3.15 还只有 rc)。 +# +# fail-fast: false —— 矩阵的价值是"一次看清哪些版本坏了", +# 有一个版本失败就掐掉其余版本,等于把这份信息丢掉。 + +on: + schedule: + # 每周一 03:00 UTC。频率按"依赖生态变化"的节奏定,不需要更密。 + - cron: '0 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + compat: + name: python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + # 装同一套 .[ci]:装得上本身就是矩阵要测的一半结论 + # (3.5/3.6/3.7 掉出下界,正是因为这一步再也解不出依赖)。 + - run: pip install -e '.[ci]' + # 只跑 test-cov:兼容矩阵问的是"这个解释器上 SDK 还能不能用"。 + # ci-syntax 不用在这里重跑——它查的是生成代码本身有没有写坏, + # 与解释器版本无关,ci.yml 在单一版本上判一次就够。 + - run: make test-cov 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)) From f8c7fe2cf157f9e800f5ead4c9aac92c3ac06c3e Mon Sep 17 00:00:00 2001 From: Episkey Date: Wed, 19 Aug 2026 01:43:28 -0700 Subject: [PATCH 3/3] =?UTF-8?q?ci:=20=E5=8E=BB=E6=8E=89=20compat.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兼容矩阵是纯巡检,不参与门禁判定,也没人会盯一个非阻断的定时任务。 声明下界还成不成立是另一个问题,要验的时候手工跑一次即可, 不必为它常驻一条每天运行的 workflow。 --- .github/workflows/ci.yml | 4 +-- .github/workflows/compat.yml | 52 ------------------------------------ 2 files changed, 2 insertions(+), 54 deletions(-) delete mode 100644 .github/workflows/compat.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c06d862d..43cd0454 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,8 @@ name: CI # # 版本取 3.12 而不是仓库声明的下界:setup.py 写着 python_requires=">=3.5", # 但那早就不成立(tests 里曾用 3.10 已移除的 collections.Callable, -# Travis 只测到 3.7 所以从未暴露)。老解释器的兼容性交给 compat.yml 定期跑, -# 不进 PR 门禁——门禁要回答的是"这次改动有没有把代码写坏"。 +# Travis 只测到 3.7 所以从未暴露)。老解释器的兼容性不进 PR 门禁—— +# 那是「声明还成不成立」的问题,门禁要回答的是"这次改动有没有把代码写坏"。 on: pull_request: diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml deleted file mode 100644 index b5aad0e0..00000000 --- a/.github/workflows/compat.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: compat - -# 解释器兼容矩阵。只挂 schedule + workflow_dispatch,**不挂 pull_request / push**: -# 它要回答的是"这个 SDK 今天还能跑在哪些 python 上",与单个 PR 改了什么无关; -# 挂到 PR 上只会在每个 codegen PR 上多出一排与本次改动无关的 check, -# 干扰门禁判读(合并门禁只认 ci.yml 里那个 ci-gate)。 -# -# 矩阵下界 3.8 —— **实测结论,不等于 setup.py 声明的下界**。 -# setup.py 写 python_requires=">=3.5"、classifiers 还列着 3.5/3.6/3.7, -# 但那三个版本今天在 CI 上根本装不出解释器,写进矩阵只会恒红: -# · actions/python-versions 对 3.7 最后只出到 3.7.17,产物仅 linux-20.04 / 22.04, -# 没有 24.04;而 actions/runner-images 的 README 里 ubuntu-latest 现指向 -# Ubuntu 24.04(26.04 还是 preview),setup-python 会直接报 -# "The version '3.7' with architecture 'x64' was not found for Ubuntu 24.04"。 -# · 3.8 则有 python-3.8.18-linux-24.04-x64 产物,装得出来。 -# · 本地实测 python 3.8.20:`pip install -e .[ci]` / `make lint` / `make test-cov` -# 均 exit 0(48 passed、37 skipped),依赖链能完整解出(sphinx 7.1.2、 -# pytest 8.3.5、flake8 7.1.2)。 -# 上界 3.14 —— actions/python-versions 当前的最新稳定版(3.15 还只有 rc)。 -# -# fail-fast: false —— 矩阵的价值是"一次看清哪些版本坏了", -# 有一个版本失败就掐掉其余版本,等于把这份信息丢掉。 - -on: - schedule: - # 每周一 03:00 UTC。频率按"依赖生态变化"的节奏定,不需要更密。 - - cron: '0 3 * * 1' - workflow_dispatch: - -permissions: - contents: read - -jobs: - compat: - name: python ${{ matrix.python-version }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: ${{ matrix.python-version }} - # 装同一套 .[ci]:装得上本身就是矩阵要测的一半结论 - # (3.5/3.6/3.7 掉出下界,正是因为这一步再也解不出依赖)。 - - run: pip install -e '.[ci]' - # 只跑 test-cov:兼容矩阵问的是"这个解释器上 SDK 还能不能用"。 - # ci-syntax 不用在这里重跑——它查的是生成代码本身有没有写坏, - # 与解释器版本无关,ci.yml 在单一版本上判一次就够。 - - run: make test-cov