Build Wheels for Sequenzo #324
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # GitHub Actions workflow for Sequenzo package | |
| name: Build Wheels for Sequenzo | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| pull_request: | |
| branches: [ main ] | |
| workflow_dispatch: | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| CIBW_VERSION: "3.1.4" | |
| jobs: | |
| build: | |
| name: Build ${{ matrix.os }} Py${{ matrix.python-version }} | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| # max-parallel: 6 | |
| matrix: | |
| include: | |
| # Linux 配置 | |
| - os: ubuntu-latest | |
| python-version: '3.9' | |
| python-version-nodot: '39' | |
| - os: ubuntu-latest | |
| python-version: '3.10' | |
| python-version-nodot: '310' | |
| - os: ubuntu-latest | |
| python-version: '3.11' | |
| python-version-nodot: '311' | |
| - os: ubuntu-latest | |
| python-version: '3.12' | |
| python-version-nodot: '312' | |
| - os: ubuntu-latest | |
| python-version: '3.13' | |
| python-version-nodot: '313' | |
| - os: ubuntu-latest | |
| python-version: '3.14' | |
| python-version-nodot: '314' | |
| # Windows 配置 | |
| - os: windows-latest | |
| python-version: '3.9' | |
| python-version-nodot: '39' | |
| - os: windows-latest | |
| python-version: '3.10' | |
| python-version-nodot: '310' | |
| - os: windows-latest | |
| python-version: '3.11' | |
| python-version-nodot: '311' | |
| - os: windows-latest | |
| python-version: '3.12' | |
| python-version-nodot: '312' | |
| - os: windows-latest | |
| python-version: '3.13' | |
| python-version-nodot: '313' | |
| # - os: windows-latest | |
| # python-version: '3.14' | |
| # python-version-nodot: '314' | |
| # macOS x86_64 | |
| - os: macos-15-intel # 使用 Intel Mac 运行器 | |
| python-version: '3.9' | |
| python-version-nodot: '39' | |
| macos-arch: 'x86_64' | |
| macos-group: intel | |
| - os: macos-15-intel | |
| python-version: '3.10' | |
| python-version-nodot: '310' | |
| macos-arch: 'x86_64' | |
| macos-group: intel | |
| - os: macos-15-intel | |
| python-version: '3.11' | |
| python-version-nodot: '311' | |
| macos-arch: 'x86_64' | |
| macos-group: intel | |
| - os: macos-15-intel | |
| python-version: '3.12' | |
| python-version-nodot: '312' | |
| macos-arch: 'x86_64' | |
| macos-group: intel | |
| - os: macos-15-intel | |
| python-version: '3.13' | |
| python-version-nodot: '313' | |
| macos-arch: 'x86_64' | |
| macos-group: intel | |
| - os: macos-15-intel | |
| python-version: '3.14' | |
| python-version-nodot: '314' | |
| macos-arch: 'x86_64' | |
| macos-group: intel | |
| # macOS arm64 | |
| - os: macos-latest # 使用 Apple Silicon Mac 运行器 | |
| python-version: '3.9' | |
| python-version-nodot: '39' | |
| macos-arch: 'arm64' | |
| macos-group: apple-silicon | |
| - os: macos-latest | |
| python-version: '3.10' | |
| python-version-nodot: '310' | |
| macos-arch: 'arm64' | |
| macos-group: apple-silicon | |
| - os: macos-latest | |
| python-version: '3.11' | |
| python-version-nodot: '311' | |
| macos-arch: 'arm64' | |
| macos-group: apple-silicon | |
| - os: macos-latest | |
| python-version: '3.12' | |
| python-version-nodot: '312' | |
| macos-arch: 'arm64' | |
| macos-group: apple-silicon | |
| - os: macos-latest | |
| python-version: '3.13' | |
| python-version-nodot: '313' | |
| macos-arch: 'arm64' | |
| macos-group: apple-silicon | |
| - os: macos-latest | |
| python-version: '3.14' | |
| python-version-nodot: '314' | |
| macos-arch: 'arm64' | |
| macos-group: apple-silicon | |
| env: | |
| PIP_DEFAULT_TIMEOUT: "120" | |
| PIP_RETRIES: "10" | |
| PIP_DISABLE_PIP_VERSION_CHECK: "1" | |
| HOMEBREW_NO_AUTO_UPDATE: "1" | |
| HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| CIBW_DEPENDENCY_VERSIONS: "pinned" | |
| steps: | |
| - name: Checkout source with submodules | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: recursive | |
| - name: Set up Python | |
| uses: actions/setup-python@v4 | |
| with: | |
| # cibuildwheel>=3 requires the runner Python to be >=3.11. | |
| # Target wheel tags are still controlled by CIBW_BUILD (cp39/cp310/cp311/cp312). | |
| python-version: "3.11" | |
| - name: Cache pip (Linux) | |
| if: runner.os == 'Linux' | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.cache/pip | |
| key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml', 'setup.py', 'setup.cfg', '.github/workflows/python-app.yml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-py${{ matrix.python-version }}-pip- | |
| ${{ runner.os }}-pip- | |
| - name: Cache pip (macOS) | |
| if: runner.os == 'macOS' | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/Library/Caches/pip | |
| key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml', 'setup.py', 'setup.cfg', '.github/workflows/python-app.yml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-py${{ matrix.python-version }}-pip- | |
| ${{ runner.os }}-pip- | |
| - name: Cache pip (Windows) | |
| if: runner.os == 'Windows' | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~\AppData\Local\pip\Cache | |
| key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml', 'setup.py', 'setup.cfg', '.github/workflows/python-app.yml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-py${{ matrix.python-version }}-pip- | |
| ${{ runner.os }}-pip- | |
| - name: Cache cibuildwheel virtualenv download (Linux) | |
| if: runner.os == 'Linux' | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/pypa/cibuildwheel/Cache | |
| ~/.cache/cibuildwheel | |
| key: ${{ runner.os }}-py${{ matrix.python-version }}-cibw-${{ env.CIBW_VERSION }} | |
| restore-keys: | | |
| ${{ runner.os }}-py${{ matrix.python-version }}-cibw- | |
| ${{ runner.os }}-cibw- | |
| - name: Cache cibuildwheel virtualenv download (macOS) | |
| if: runner.os == 'macOS' | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/Library/Caches/pypa/cibuildwheel/Cache | |
| ~/Library/Caches/cibuildwheel | |
| key: ${{ runner.os }}-py${{ matrix.python-version }}-cibw-${{ env.CIBW_VERSION }} | |
| restore-keys: | | |
| ${{ runner.os }}-py${{ matrix.python-version }}-cibw- | |
| ${{ runner.os }}-cibw- | |
| - name: Cache cibuildwheel virtualenv download (Windows) | |
| if: runner.os == 'Windows' | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~\AppData\Local\pypa\cibuildwheel\Cache | |
| key: ${{ runner.os }}-py${{ matrix.python-version }}-cibw-${{ env.CIBW_VERSION }} | |
| restore-keys: | | |
| ${{ runner.os }}-py${{ matrix.python-version }}-cibw- | |
| ${{ runner.os }}-cibw- | |
| - name: Warm cibuildwheel virtualenv cache with retry | |
| if: ${{ false }} # Disabled: this warm-up can amplify GitHub rate limits under matrix builds. | |
| continue-on-error: true | |
| run: | | |
| python - <<'PY' | |
| import os | |
| import shutil | |
| import sys | |
| import time | |
| import urllib.request | |
| from pathlib import Path | |
| version = "20.30.0" | |
| url = f"https://raw.githubusercontent.com/pypa/get-virtualenv/{version}/public/virtualenv.pyz" | |
| filename = f"virtualenv-{version}.pyz" | |
| home = Path.home() | |
| candidates = [] | |
| if os.name == "nt": | |
| local_appdata = os.environ.get("LOCALAPPDATA") | |
| if local_appdata: | |
| candidates.append(Path(local_appdata) / "pypa" / "cibuildwheel" / "Cache" / filename) | |
| candidates.append(home / "AppData" / "Local" / "pypa" / "cibuildwheel" / "Cache" / filename) | |
| elif sys.platform == "darwin": | |
| candidates.append(home / "Library" / "Caches" / "pypa" / "cibuildwheel" / "Cache" / filename) | |
| candidates.append(home / "Library" / "Caches" / "cibuildwheel" / filename) | |
| else: | |
| candidates.append(home / ".cache" / "pypa" / "cibuildwheel" / "Cache" / filename) | |
| candidates.append(home / ".cache" / "cibuildwheel" / filename) | |
| existing = next((p for p in candidates if p.exists()), None) | |
| if existing: | |
| print(f"[cache-hit] {existing}") | |
| sys.exit(0) | |
| tmp_file = Path.cwd() / filename | |
| for attempt in range(1, 6): | |
| try: | |
| print(f"[download] attempt {attempt}/5: {url}") | |
| req = urllib.request.Request( | |
| url, | |
| headers={"User-Agent": "sequenzo-ci/1.0 (+github-actions)"} | |
| ) | |
| with urllib.request.urlopen(req, timeout=120) as response: | |
| data = response.read() | |
| tmp_file.write_bytes(data) | |
| print(f"[download] completed, size={len(data)} bytes") | |
| break | |
| except Exception as exc: | |
| if attempt == 5: | |
| print(f"[download] giving up after 5 attempts: {exc}") | |
| sys.exit(0) | |
| sleep_s = attempt * 20 | |
| print(f"[download] failed: {exc}. retry in {sleep_s}s") | |
| time.sleep(sleep_s) | |
| for path in candidates: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(tmp_file, path) | |
| print(f"[cache-write] {path}") | |
| tmp_file.unlink(missing_ok=True) | |
| PY | |
| shell: bash | |
| - name: Ensure setuptools/wheel for macOS | |
| if: runner.os == 'macOS' | |
| run: python -m pip install --upgrade --retries 10 --timeout 120 pip setuptools wheel "cibuildwheel==${CIBW_VERSION}" | |
| # Build libomp from LLVM source so the bundled dylib's minos matches the | |
| # wheel deployment target (Homebrew libomp 18+ requires macOS 13+, which | |
| # would break the minos guarantee of macosx_10_15 / macosx_11_0 wheels). | |
| # | |
| # Previously this caused "symbol not found in flat namespace | |
| # '___kmpc_dispatch_deinit'" at import time. Root cause: Apple Clang emits | |
| # calls to __kmpc_dispatch_deinit for schedule(dynamic/guided) loops, but the | |
| # LLVM openmp runtime we build from source omits it (removed in LLVM 13). | |
| # -fno-openmp-extensions does NOT suppress this symbol on modern Apple Clang. | |
| # Fix: inject a no-op __kmpc_dispatch_deinit stub into the CI libomp build | |
| # (see build_macos_ci_libomp.sh) so bundled libomp satisfies the reference. | |
| - name: Cache CI libomp (macOS) | |
| if: runner.os == 'macOS' | |
| uses: actions/cache@v4 | |
| with: | |
| path: .local-libomp | |
| key: macos-libomp-${{ matrix.macos-arch }}-${{ matrix.macos-arch == 'x86_64' && '10.15' || '11.0' }}-llvm18.1.8-${{ hashFiles('maintenance_scripts/build_macos_ci_libomp.sh') }} | |
| - name: Install OpenMP (macOS) | |
| if: runner.os == 'macOS' | |
| run: | | |
| set -euo pipefail | |
| if [ "${{ matrix.macos-arch }}" == "x86_64" ]; then | |
| MACOS_DEPLOY_TARGET="10.15" | |
| else | |
| MACOS_DEPLOY_TARGET="11.0" | |
| fi | |
| chmod +x maintenance_scripts/build_macos_ci_libomp.sh | |
| maintenance_scripts/build_macos_ci_libomp.sh \ | |
| "${{ matrix.macos-arch }}" \ | |
| "$MACOS_DEPLOY_TARGET" \ | |
| "$GITHUB_WORKSPACE/.local-libomp" | |
| LIBOMP_PREFIX="$GITHUB_WORKSPACE/.local-libomp" | |
| echo "CI libomp prefix: $LIBOMP_PREFIX" | |
| file "$LIBOMP_PREFIX/lib/libomp.dylib" | |
| otool -l "$LIBOMP_PREFIX/lib/libomp.dylib" | awk '/LC_BUILD_VERSION|LC_VERSION_MIN_MACOSX|minos|version/' | |
| { | |
| echo "CC=clang" | |
| echo "CXX=clang++" | |
| echo "MACOSX_DEPLOYMENT_TARGET=$MACOS_DEPLOY_TARGET" | |
| echo "LDFLAGS=-L$LIBOMP_PREFIX/lib -Wl,-rpath,@loader_path/../.dylibs" | |
| echo "CPPFLAGS=-I$LIBOMP_PREFIX/include" | |
| echo "SEQUENZO_ENABLE_OPENMP=1" | |
| echo "SEQUENZO_LIBOMP_PREFIX=$LIBOMP_PREFIX" | |
| echo "LIBOMP_PATH=$LIBOMP_PREFIX/lib" | |
| } >> "$GITHUB_ENV" | |
| - name: Install OpenMP (Linux) | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libomp-dev | |
| echo "SEQUENZO_ENABLE_OPENMP=1" >> $GITHUB_ENV | |
| - name: Setup MSVC with OpenMP (Windows) | |
| if: runner.os == 'Windows' | |
| uses: ilammy/msvc-dev-cmd@v1 | |
| with: | |
| arch: x64 | |
| - name: Enable OpenMP (Windows) | |
| if: runner.os == 'Windows' | |
| run: | | |
| echo "SEQUENZO_ENABLE_OPENMP=1" >> $env:GITHUB_ENV | |
| - name: Set Windows encoding (Windows only) | |
| if: runner.os == 'Windows' | |
| run: | | |
| echo "PYTHONIOENCODING=utf-8" >> $env:GITHUB_ENV | |
| echo "Setting Python encoding to UTF-8 for Windows" | |
| - name: Sanitize PATH for Windows linker resolution | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| $parts = $env:PATH -split ';' | Where-Object { | |
| $_ -and ($_ -notmatch '(?i)\\Git\\usr\\bin\\?$') | |
| } | |
| $cleanPath = $parts -join ';' | |
| "PATH=$cleanPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append | |
| $env:PATH = $cleanPath | |
| Write-Host "Resolved cl/link after PATH cleanup:" | |
| where.exe cl | |
| where.exe link | |
| - name: Locate MSVC LLVM OpenMP runtime (Windows) | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" | |
| $installPath = & $vswhere -latest -products * -property installationPath | |
| if (-not $installPath) { | |
| throw "vswhere did not return a Visual Studio installation path" | |
| } | |
| $releaseMatches = Get-ChildItem -Path "$installPath\VC\Redist\MSVC\*\x64\Microsoft.VC*.OpenMP.LLVM\libomp140.x86_64.dll" -ErrorAction SilentlyContinue | Sort-Object FullName | |
| $debugMatches = Get-ChildItem -Path "$installPath\VC\Redist\MSVC\*\debug_nonredist\x64\Microsoft.VC*.OpenMP.LLVM\libomp140.x86_64.dll" -ErrorAction SilentlyContinue | Sort-Object FullName | |
| $llvmMatches = @( | |
| Get-ChildItem -Path "$installPath\VC\Tools\Llvm\x64\bin\libomp140.x86_64.dll" -ErrorAction SilentlyContinue | |
| Get-ChildItem -Path "$installPath\VC\Tools\Llvm\x64\bin\libomp140.dll" -ErrorAction SilentlyContinue | |
| ) | Sort-Object FullName | |
| $dll = $null | |
| if ($releaseMatches) { | |
| $dll = $releaseMatches[-1] | |
| } elseif ($debugMatches) { | |
| $dll = $debugMatches[-1] | |
| Write-Host "WARNING: using debug_nonredist OpenMP runtime: $($dll.FullName)" | |
| } elseif ($llvmMatches) { | |
| $dll = $llvmMatches[-1] | |
| } | |
| if (-not $dll) { | |
| throw "libomp140*.dll not found under Visual Studio install: $installPath" | |
| } | |
| Write-Host "Found OpenMP runtime: $($dll.FullName)" | |
| "LIBOMP_DLL_DIR=$($dll.DirectoryName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append | |
| # Install dependencies with fallback strategy | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade --retries 10 --timeout 120 pip setuptools wheel "cibuildwheel==${CIBW_VERSION}" | |
| # Use oldest-supported-numpy for builds to ensure forward compatibility | |
| pip install --retries 10 --timeout 120 oldest-supported-numpy || pip install --retries 10 --timeout 120 "numpy>=1.21.0" | |
| pip install --retries 10 --timeout 120 --only-binary=all "scipy<1.16" || echo "scipy will be installed in cibuildwheel" | |
| pip install --retries 10 --timeout 120 --prefer-binary Cython pybind11 build twine | |
| shell: bash | |
| - name: Build wheels with cibuildwheel | |
| run: | | |
| set -euo pipefail | |
| for attempt in 1 2 3; do | |
| echo "cibuildwheel attempt ${attempt}/3" | |
| if python -m cibuildwheel --output-dir dist; then | |
| exit 0 | |
| fi | |
| if [ "$attempt" -lt 3 ]; then | |
| sleep_seconds=$((attempt * 30)) | |
| echo "cibuildwheel failed, retrying in ${sleep_seconds}s..." | |
| sleep "$sleep_seconds" | |
| fi | |
| done | |
| echo "cibuildwheel failed after 3 attempts" | |
| exit 1 | |
| shell: bash | |
| env: | |
| CIBW_BUILD: "cp${{ matrix.python-version-nodot }}-*" # 只构建当前 Python 版本的轮子 (e.g., cp39-*, cp310-*) | |
| CIBW_SKIP: "*-musllinux* pp*" # 跳过 musllinux 和 PyPy | |
| # hmmlearn has no Windows py3.14 binary wheel yet (3.14 is still pre-release) | |
| # and its source build fails due to link.exe PATH issues in cibuildwheel's | |
| # isolated test venv. Skip the test phase only; the wheel is still built. | |
| CIBW_TEST_SKIP: "cp314-win_amd64" | |
| CIBW_ARCHS_WINDOWS: "AMD64" | |
| CIBW_ARCHS_LINUX: "x86_64" | |
| CIBW_ARCHS_MACOS: "${{ matrix.macos-arch }}" | |
| # Alternative: Use separate architecture wheels if universal2 causes issues | |
| # CIBW_ARCHS_MACOS: "x86_64 arm64" # Build separate wheels for each architecture | |
| # 或者根据架构设置不同的目标 | |
| # Build libomp from source in CI so bundled dylib matches these targets. | |
| CIBW_TARGET_OSX_x86_64: "10.15" | |
| CIBW_TARGET_OSX_arm64: "11.0" | |
| CIBW_BUILD_VERBOSITY: "3" | |
| # Environment variables | |
| CIBW_ENVIRONMENT_LINUX: SEQUENZO_ENABLE_OPENMP=1 | |
| CIBW_ENVIRONMENT_WINDOWS: SEQUENZO_ENABLE_OPENMP=1 DISTUTILS_USE_SDK=1 | |
| # 显式将 runner 级别的环境变量传入 cibuildwheel 的隔离 venv | |
| # 避免 $LIBOMP_PATH 因隐式继承失败而导致 delocate 找不到 libomp | |
| CIBW_PASS_ENV: "LIBOMP_PATH LIBOMP_DLL_DIR SEQUENZO_LIBOMP_PREFIX SEQUENZO_ENABLE_OPENMP MACOSX_DEPLOYMENT_TARGET GITHUB_WORKSPACE" | |
| # macOS: Set OpenMP environment with proper library paths and rpath for bundling | |
| # REPAIR_LIBRARY_PATH 是 libomp 路径的副本,专用于在 repair 阶段 | |
| # 内部设置 DYLD_LIBRARY_PATH(macOS SIP 会剥离子进程继承的 DYLD_LIBRARY_PATH) | |
| CIBW_ENVIRONMENT_MACOS: > | |
| SEQUENZO_ENABLE_OPENMP=1 | |
| REPAIR_LIBRARY_PATH="$LIBOMP_PATH" | |
| LDFLAGS="-L$LIBOMP_PATH -Wl,-rpath,@loader_path/../.dylibs" | |
| CPPFLAGS="-I$LIBOMP_PATH/../include" | |
| CFLAGS="-fno-openmp-extensions" | |
| CXXFLAGS="-fno-openmp-extensions" | |
| # macOS: ensure libomp is available and properly linked | |
| CIBW_BEFORE_BUILD_MACOS: | | |
| echo "Installing build dependencies for macOS" | |
| python -m pip install --upgrade pip | |
| CURRENT_ARCH=$(uname -m) | |
| echo "Building for architecture: $CURRENT_ARCH" | |
| # 安装 delocate | |
| echo "Installing delocate for wheel repair..." | |
| pip install delocate | |
| # 验证 CI 构建的 libomp(兼容 wheel 最低 macOS 版本) | |
| if [ -n "${SEQUENZO_LIBOMP_PREFIX:-}" ] && [ -f "$SEQUENZO_LIBOMP_PREFIX/lib/libomp.dylib" ]; then | |
| echo "[OK] CI libomp found at: $SEQUENZO_LIBOMP_PREFIX" | |
| file "$SEQUENZO_LIBOMP_PREFIX/lib/libomp.dylib" | |
| elif [ -d "$LIBOMP_PATH" ]; then | |
| echo "[OK] libomp found at: $LIBOMP_PATH" | |
| if [ -f "$LIBOMP_PATH/libomp.dylib" ]; then | |
| echo "libomp.dylib architecture:" | |
| file "$LIBOMP_PATH/libomp.dylib" | |
| fi | |
| else | |
| echo "[WARNING] libomp not found at $LIBOMP_PATH" | |
| fi | |
| # macOS: Use delocate to bundle libomp into the wheel | |
| # 修复关键点: | |
| # 1. 在 repair 命令内部显式设置 DYLD_LIBRARY_PATH | |
| # macOS SIP 会在子进程启动时剥离 DYLD_LIBRARY_PATH, | |
| # 所以必须在 shell 命令内临时设置,delocate 才能找到 libomp.dylib。 | |
| # 2. 添加 --require-archs {delocate_archs} 确保打包的 dylib 架构 | |
| # 与 .so 文件一致(防止 arm64/x86_64 混淆导致用户端 crash)。 | |
| # 3. wheel repair 失败时必须让 CI 失败,不能发布未打包 libomp 的 macOS wheel。 | |
| CIBW_REPAIR_WHEEL_COMMAND_MACOS: | | |
| echo "=== Repairing macOS wheel ===" | |
| echo "Current architecture: $(uname -m)" | |
| echo "Wheel: {wheel}" | |
| echo "REPAIR_LIBRARY_PATH: ${REPAIR_LIBRARY_PATH}" | |
| # 在 repair 命令内部设置 DYLD_LIBRARY_PATH,绕过 macOS SIP 的剥离 | |
| DYLD_LIBRARY_PATH="${REPAIR_LIBRARY_PATH}:${DYLD_LIBRARY_PATH:-}" \ | |
| delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} || { | |
| echo "[ERROR] delocate failed; refusing to publish an unrepaired macOS wheel." | |
| exit 1 | |
| } | |
| # 验证修复结果:确认 libomp 是否真的打包进去了 | |
| if ls {dest_dir}/*.whl 1> /dev/null 2>&1; then | |
| REPAIRED_WHEEL=$(ls {dest_dir}/*.whl | head -1) | |
| echo "Repaired wheel: $REPAIRED_WHEEL" | |
| echo "=== Bundled dependencies ===" | |
| delocate-listdeps --all "$REPAIRED_WHEEL" 2>&1 || echo "Dependency listing failed" | |
| if delocate-listdeps --all "$REPAIRED_WHEEL" 2>&1 | grep -q "libomp"; then | |
| echo "[OK] libomp successfully bundled into wheel" | |
| else | |
| echo "[ERROR] libomp is not bundled into the macOS wheel." | |
| exit 1 | |
| fi | |
| fi | |
| # Linux: minimal build dependencies (skip R/rpy2 - they're optional) | |
| CIBW_BEFORE_BUILD_LINUX: | | |
| echo "Installing minimal build dependencies for Linux" | |
| python -m pip install --upgrade pip | |
| echo "Skipping R/rpy2 installation - they are optional dependencies" | |
| # 修复 Linux 构建问题 | |
| echo "=== Preparing clean Linux build ===" | |
| python -m pip install --upgrade pip | |
| echo "Ensuring clean build environment..." | |
| # 再次清理,确保没有残留的项目构建文件;不要全仓库扫描依赖/实验目录。 | |
| find ./sequenzo -name "*.so" -type f -delete 2>/dev/null || true | |
| find . -maxdepth 1 -name "*.so" -type f -delete 2>/dev/null || true | |
| find ./sequenzo -path "*/utils/*.c" -type f -print0 | while IFS= read -r -d '' cfile; do | |
| pyxfile="${cfile%.c}.pyx" | |
| if [ -f "$pyxfile" ]; then | |
| rm -f "$cfile" | |
| fi | |
| done | |
| rm -rf build/ dist/ *.egg-info | |
| echo "Clean build environment ready" | |
| # 使用绝对路径验证源文件 | |
| echo "Current working directory: $(pwd)" | |
| echo "Absolute path verification:" | |
| if [ -f "$(pwd)/sequenzo/dissimilarity_measures/src/module.cpp" ]; then | |
| echo "[SUCCESS] module.cpp found at absolute path: $(pwd)/sequenzo/dissimilarity_measures/src/module.cpp" | |
| else | |
| echo "[ERROR] module.cpp NOT FOUND at absolute path: $(pwd)/sequenzo/dissimilarity_measures/src/module.cpp" | |
| echo "Available files in src directory:" | |
| ls -la "$(pwd)/sequenzo/dissimilarity_measures/src/" 2>/dev/null || echo "Cannot list src directory" | |
| fi | |
| # 设置 Linux 修复策略 | |
| CIBW_REPAIR_WHEEL_COMMAND_LINUX: | | |
| echo "=== Attempting wheel repair ===" | |
| echo "Wheel to repair: {wheel}" | |
| # wheel repair 失败时必须让 CI 失败,不能发布未修复 wheel。 | |
| auditwheel repair -w {dest_dir} {wheel} || { | |
| echo "[ERROR] auditwheel repair failed; refusing to publish an unrepaired Linux wheel." | |
| exit 1 | |
| } | |
| # 检查修复结果 | |
| if ls {dest_dir}/*.whl 1> /dev/null 2>&1; then | |
| REPAIRED_WHEEL=$(ls {dest_dir}/*.whl | head -1) | |
| echo "Final wheel: $REPAIRED_WHEEL" | |
| auditwheel show "$REPAIRED_WHEEL" || echo "Cannot show wheel info" | |
| fi | |
| # Windows: setup MSVC environment | |
| CIBW_BEFORE_BUILD_WINDOWS: | | |
| echo "Installing wheel repair dependencies" | |
| python -m pip install --upgrade pip | |
| python -m pip install delvewheel | |
| echo "Setting up MSVC environment" | |
| call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars64.bat" | |
| for /f "usebackq tokens=*" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find VC\Tools\MSVC\**\bin\Hostx64\x64`) do set "VCTOOLS_BIN=%%i" | |
| if not defined VCTOOLS_BIN ( | |
| echo "ERROR: Unable to locate MSVC linker path via vswhere" | |
| exit /b 1 | |
| ) | |
| set "PATH=%VCTOOLS_BIN%;%PATH%" | |
| where cl | |
| where link | |
| echo "Using minimal build dependencies" | |
| CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: python maintenance_scripts/repair_windows_wheel.py "{dest_dir}" "{wheel}" | |
| # 注意:CIBW_BEFORE_BUILD(通用)在所有平台都提供了平台专用版本时 | |
| # 永远不会执行(平台专用变体会覆盖通用变体),已移除死代码。 | |
| # Test command: use heredoc to keep the entire script in one shell command (fix Linux quoting) | |
| # macOS test: intentionally does NOT set DYLD_LIBRARY_PATH to any system | |
| # libomp. The wheel must be self-contained after delocate repair: the | |
| # bundled .dylibs/libomp.dylib (built from LLVM source in CI) must satisfy | |
| # all OpenMP symbols referenced by the .so files. | |
| # | |
| # Previously this command pre-loaded Homebrew libomp via DYLD_LIBRARY_PATH, | |
| # which masked a "symbol not found in flat namespace '___kmpc_dispatch_deinit'" | |
| # bug: delocate bundles the LLVM-source libomp unless our dispatch_deinit stub | |
| # is present. The pre-load caused CI tests to pass while users hit the error | |
| # locally. Fix: test without any system-libomp fallback so the CI gate is honest. | |
| CIBW_TEST_COMMAND_MACOS: | | |
| echo "=== Testing macOS wheel (bundled libomp only — no DYLD_LIBRARY_PATH fallback) ===" | |
| echo "Architecture: $(uname -m)" | |
| echo "Python version: $(python --version)" | |
| # Verify the wheel is self-contained: .dylibs must exist and contain libomp | |
| python - <<'PY' | |
| import sequenzo, os, glob, sys | |
| pkg_dir = os.path.dirname(sequenzo.__file__) | |
| dylibs_dir = os.path.join(pkg_dir, '.dylibs') | |
| if os.path.exists(dylibs_dir): | |
| libs = glob.glob(os.path.join(dylibs_dir, '*.dylib')) | |
| print(f'[OK] .dylibs found: {[os.path.basename(l) for l in libs]}') | |
| if not any('libomp' in os.path.basename(l) for l in libs): | |
| print('[ERROR] libomp not bundled into wheel .dylibs/') | |
| sys.exit(1) | |
| else: | |
| print('[ERROR] .dylibs directory missing — delocate repair may have failed') | |
| sys.exit(1) | |
| PY | |
| echo "" | |
| echo "=== Testing Sequenzo import ===" | |
| python - <<'PY' | |
| import importlib.util | |
| import sys | |
| import numpy as np | |
| try: | |
| import sequenzo | |
| print('[OK] sequenzo import successful') | |
| except ImportError as e: | |
| print(f'[ERROR] Failed to import sequenzo: {e}') | |
| sys.exit(1) | |
| # clustering_c_code uses OpenMP dynamic schedules — this module previously | |
| # triggered "symbol not found in flat namespace '___kmpc_dispatch_deinit'" | |
| # when bundled libomp lacked the dispatch_deinit compatibility stub. | |
| try: | |
| import sequenzo.clustering.clustering_c_code | |
| print('[OK] clustering_c_code loaded (bundled libomp sufficient)') | |
| except ImportError as e: | |
| print(f'[ERROR] clustering_c_code failed to load: {e}') | |
| if 'kmpc_dispatch_deinit' in str(e): | |
| print('[ERROR] ___kmpc_dispatch_deinit missing from bundled libomp.') | |
| print('[ERROR] Ensure build_macos_ci_libomp.sh injects the dispatch_deinit stub.') | |
| sys.exit(1) | |
| try: | |
| from sequenzo.dissimilarity_measures import c_code | |
| print('[OK] dissimilarity_measures c_code loaded') | |
| for cls in ['OMdistance', 'OMspellDistance', 'DHDdistance', 'LCPdistance', 'dist2matrix']: | |
| if hasattr(c_code, cls): | |
| print(f'[OK] {cls} available') | |
| except ImportError as e: | |
| print(f'[WARNING] dissimilarity_measures c_code failed: {e}') | |
| for module_name in ['get_sm_trate_substitution_cost_matrix', 'seqconc', 'seqdss', 'seqdur', 'seqlength']: | |
| try: | |
| __import__(f'sequenzo.dissimilarity_measures.utils.{module_name}', fromlist=[module_name]) | |
| print(f'[OK] Cython module {module_name} loaded') | |
| except ImportError as e: | |
| print(f'[WARNING] Cython module {module_name} failed: {e}') | |
| print('') | |
| print('=== Testing sequenzo_fastcluster module ===') | |
| try: | |
| from sequenzo.clustering.sequenzo_fastcluster.fastcluster import linkage, linkage_vector | |
| from scipy.spatial.distance import pdist | |
| test_data = np.array([[1, 2], [1, 4], [1, 0], [4, 2], [4, 4], [4, 0]]) | |
| dist_matrix = pdist(test_data) | |
| for method in ['single', 'complete', 'average']: | |
| Z = linkage(dist_matrix, method=method) | |
| assert Z.shape == (len(test_data) - 1, 4) and np.all(np.isfinite(Z)) | |
| print(f'[OK] linkage({method}) works') | |
| # linkage_vector only supports: single, ward, centroid, median | |
| # 'complete' / 'average' are valid for linkage() but NOT linkage_vector() | |
| Z_vec = linkage_vector(test_data, method='ward', metric='euclidean') | |
| assert Z_vec.shape == (len(test_data) - 1, 4) and np.all(np.isfinite(Z_vec)) | |
| print('[OK] linkage_vector works') | |
| except ImportError as e: | |
| print(f'[WARNING] sequenzo_fastcluster not available: {e}') | |
| print('') | |
| print('[INFO] macOS wheel test completed') | |
| PY | |
| # Linux/other platforms test command (original) | |
| CIBW_TEST_COMMAND: | | |
| python - <<'PY' | |
| import importlib.util | |
| import sys | |
| import numpy as np | |
| import sequenzo | |
| print('Sequenzo import successful') | |
| mod = importlib.util.find_spec('sequenzo.clustering.clustering_c_code') | |
| print('Clustering C++ extensions loaded' if mod else 'WARNING: Clustering C++ extensions not found') | |
| try: | |
| from sequenzo.dissimilarity_measures import c_code | |
| print('Dissimilarity measures C++ extensions (c_code) loaded successfully') | |
| # Test specific C++ classes | |
| try: | |
| # Test if classes are available (don't instantiate to avoid errors) | |
| if hasattr(c_code, 'OMdistance'): | |
| print('[OK] OMdistance class available') | |
| if hasattr(c_code, 'OMspellDistance'): | |
| print('[OK] OMspellDistance class available') | |
| if hasattr(c_code, 'DHDdistance'): | |
| print('[OK] DHDdistance class available') | |
| if hasattr(c_code, 'LCPdistance'): | |
| print('[OK] LCPdistance class available') | |
| if hasattr(c_code, 'dist2matrix'): | |
| print('[OK] dist2matrix class available') | |
| except Exception as e: | |
| print(f'WARNING: Some C++ classes may not be available: {e}') | |
| except ImportError as e: | |
| print(f'ERROR: Dissimilarity measures C++ extensions (c_code) failed to load: {e}') | |
| cython_modules = [ | |
| 'get_sm_trate_substitution_cost_matrix', | |
| 'seqconc', | |
| 'seqdss', | |
| 'seqdur', | |
| 'seqlength' | |
| ] | |
| for module_name in cython_modules: | |
| try: | |
| module = __import__(f'sequenzo.dissimilarity_measures.utils.{module_name}', fromlist=[module_name]) | |
| print(f'[OK] Cython module {module_name} loaded successfully') | |
| except ImportError as e: | |
| print(f'ERROR: Cython module {module_name} failed to load: {e}') | |
| try: | |
| from sequenzo.dissimilarity_measures.utils import ( | |
| get_sm_trate_substitution_cost_matrix, seqconc, seqdss, seqdur, seqlength | |
| ) | |
| print('[OK] All utility functions imported successfully') | |
| except ImportError as e: | |
| print(f'ERROR: Utility functions failed to load: {e}') | |
| # Check sequenzo_fastcluster module | |
| print('') | |
| print('=== Testing sequenzo_fastcluster module ===') | |
| try: | |
| from sequenzo.clustering.sequenzo_fastcluster.fastcluster import linkage, linkage_vector, single, complete, average | |
| print('[OK] sequenzo_fastcluster wrapper imported successfully') | |
| # Test linkage function with small dataset | |
| from scipy.spatial.distance import pdist | |
| test_data = np.array([[1, 2], [1, 4], [1, 0], [4, 2], [4, 4], [4, 0]]) | |
| dist_matrix = pdist(test_data) | |
| # Test different linkage methods | |
| for method in ['single', 'complete', 'average']: | |
| try: | |
| Z = linkage(dist_matrix, method=method) | |
| assert Z.shape == (len(test_data) - 1, 4) | |
| assert np.all(np.isfinite(Z)) | |
| print(f'[OK] linkage({method}) works correctly') | |
| except Exception as e: | |
| print(f'[WARNING] linkage({method}) failed: {e}') | |
| # Test linkage_vector | |
| # linkage_vector only supports: single, ward, centroid, median | |
| try: | |
| Z_vec = linkage_vector(test_data, method='ward', metric='euclidean') | |
| assert Z_vec.shape == (len(test_data) - 1, 4) | |
| assert np.all(np.isfinite(Z_vec)) | |
| print('[OK] linkage_vector works correctly') | |
| except Exception as e: | |
| print(f'[WARNING] linkage_vector failed: {e}') | |
| except ImportError as e: | |
| print(f'[WARNING] sequenzo_fastcluster module not available: {e}') | |
| print('[INFO] This module provides optimized hierarchical clustering') | |
| print('[INFO] All tests completed') | |
| PY | |
| # Windows smoke test uses cmd-compatible python -c. | |
| CIBW_TEST_COMMAND_WINDOWS: | | |
| echo "Testing Sequenzo wheel on Windows" | |
| python -c "import numpy as np; from sequenzo import SequenceData, get_distance_matrix, Cluster; import sequenzo.utils.core_distance_operations.core_distance_c_code as core_distance_c_code; from sequenzo.utils.core_distance_operations import weighted_inertia_contrib; contrib = weighted_inertia_contrib(np.array([[0.0, 1.0], [1.0, 0.0]], dtype=np.float64), np.array([0, 1], dtype=np.int32), np.array([1.0, 1.0], dtype=np.float64)); assert contrib.shape == (2,); print('[OK] Windows wheel smoke test passed')" | |
| - name: Show dist content | |
| run: ls -lah dist/ | |
| shell: bash | |
| # Verify macOS wheels can resolve ___kmpc_dispatch_deinit at load time. | |
| # Apple Clang emits calls to __kmpc_dispatch_deinit for schedule(dynamic/guided) | |
| # loops; -fno-openmp-extensions does NOT suppress this on modern Apple Clang. | |
| # nm -u on a linked .so lists the symbol as undefined (U) because it is resolved | |
| # from bundled libomp at dlopen time — that is expected and OK when libomp exports it. | |
| - name: Verify OpenMP dispatch_deinit resolvable in macOS wheels | |
| if: runner.os == 'macOS' | |
| run: | | |
| set -euo pipefail | |
| echo "=== Checking ___kmpc_dispatch_deinit resolution in macOS wheels ===" | |
| FOUND_BAD=0 | |
| for whl in dist/*.whl; do | |
| echo "Inspecting: $whl" | |
| TMPDIR_WHL=$(mktemp -d) | |
| unzip -q "$whl" -d "$TMPDIR_WHL" | |
| LIBOMP="" | |
| while IFS= read -r -d '' dylib; do | |
| case "$(basename "$dylib")" in | |
| libomp*.dylib) LIBOMP="$dylib"; break ;; | |
| esac | |
| done < <(find "$TMPDIR_WHL" -path '*/.dylibs/*.dylib' -print0) | |
| if [ -z "$LIBOMP" ]; then | |
| echo "[ERROR] No bundled libomp.dylib found in $whl" | |
| FOUND_BAD=1 | |
| rm -rf "$TMPDIR_WHL" | |
| continue | |
| fi | |
| if nm -g "$LIBOMP" 2>/dev/null | grep -q '__kmpc_dispatch_deinit'; then | |
| echo "[OK] Bundled libomp exports __kmpc_dispatch_deinit: $LIBOMP" | |
| LIBOMP_HAS_DEINIT=1 | |
| else | |
| echo "[ERROR] Bundled libomp missing __kmpc_dispatch_deinit stub: $LIBOMP" | |
| LIBOMP_HAS_DEINIT=0 | |
| FOUND_BAD=1 | |
| fi | |
| while IFS= read -r -d '' so_file; do | |
| if nm -u "$so_file" 2>/dev/null | grep -q '___kmpc_dispatch_deinit'; then | |
| if [ "$LIBOMP_HAS_DEINIT" -eq 1 ]; then | |
| echo "[OK] $so_file references dispatch_deinit; bundled libomp provides it" | |
| else | |
| echo "[ERROR] $so_file references dispatch_deinit but libomp lacks stub" | |
| FOUND_BAD=1 | |
| fi | |
| else | |
| echo "[OK] $so_file — no dispatch_deinit reference (static OpenMP schedules)" | |
| fi | |
| done < <(find "$TMPDIR_WHL" -name "*.so" -print0) | |
| rm -rf "$TMPDIR_WHL" | |
| done | |
| if [ "$FOUND_BAD" -eq 1 ]; then | |
| echo "[FATAL] macOS wheel OpenMP symbol check failed." | |
| echo " Ensure build_macos_ci_libomp.sh injects the dispatch_deinit stub." | |
| exit 1 | |
| fi | |
| echo "=== All macOS wheels pass OpenMP symbol verification ===" | |
| shell: bash | |
| - name: Check wheels | |
| run: twine check dist/* | |
| # 验证轮子 RECORD 以适应 PyPI 最新要求 | |
| - name: Verify Wheel RECORD Integrity | |
| run: | | |
| echo "=== Verifying wheel RECORD integrity ===" | |
| for wheel in dist/*.whl; do | |
| echo "Checking $wheel" | |
| # 解压并比较 RECORD 与实际文件 | |
| unzip -q $wheel -d temp_wheel | |
| cd temp_wheel | |
| # 提取 RECORD | |
| record_file=$(find . -name RECORD) | |
| if [ -f "$record_file" ]; then | |
| # 检查每个 RECORD 条目是否存在 | |
| awk -F, '{print $1}' $record_file | while read file; do | |
| if [ ! -f "$file" ]; then | |
| echo "[ERROR] Missing file in wheel: $file" | |
| fi | |
| done | |
| # 检查多余文件(可选) | |
| find . -type f | grep -v '.dist-info/' | while read extra; do | |
| if ! grep -q "^${extra#./}," $record_file; then | |
| echo "[WARNING] Extra file not in RECORD: $extra" | |
| fi | |
| done | |
| else | |
| echo "[ERROR] No RECORD file found" | |
| fi | |
| cd .. | |
| rm -rf temp_wheel | |
| done | |
| shell: bash | |
| - name: Set up Python for wheel verification | |
| uses: actions/setup-python@v4 | |
| with: | |
| # cibuildwheel is driven by Python 3.11, but wheel install/import | |
| # checks must run on the target interpreter for this matrix entry. | |
| python-version: ${{ matrix.python-version }} | |
| # allow-prereleases ensures 3.14 (beta) can be installed when it hasn't been officially released yet | |
| allow-prereleases: true | |
| - name: Verify OpenMP Support | |
| run: | | |
| echo "=== Verifying built wheels ===" | |
| python -c " | |
| import subprocess | |
| import sys | |
| import os | |
| # 精确匹配当前 Python 版本的轮子 (e.g., cp39, cp310) | |
| py_tag = 'cp${{ matrix.python-version }}'.replace('.', '') | |
| wheel_files = [f for f in os.listdir('dist') if f.endswith('.whl') and py_tag in f] | |
| if wheel_files: | |
| wheel_file = wheel_files[0] | |
| print(f'Installing wheel: {wheel_file} (Python tag: {py_tag})') | |
| subprocess.run([sys.executable, '-m', 'pip', 'install', '--force-reinstall', f'dist/{wheel_file}'], check=True) | |
| import sequenzo | |
| print('Sequenzo import successful') | |
| try: | |
| import sequenzo.clustering.clustering_c_code as cc | |
| print('C++ extensions loaded successfully') | |
| except ImportError as e: | |
| print(f'WARNING: C++ extensions loading failed: {e}') | |
| else: | |
| print('ERROR: No matching wheel files found') | |
| " || echo "Verification completed with warnings" | |
| shell: bash | |
| - name: Verify built Cython modules | |
| run: | | |
| echo "=== Verifying built cython modules in wheel ===" | |
| # 安装与当前 matrix Python 版本匹配的轮子 | |
| py_tag="cp${{ matrix.python-version-nodot }}" | |
| WHEEL_FILE=$(ls dist/*.whl | grep "$py_tag" | head -1 || true) | |
| if [ -z "$WHEEL_FILE" ]; then | |
| echo "No wheel matches tag $py_tag in dist/" | |
| ls -la dist/ | |
| exit 1 | |
| fi | |
| echo "Installing wheel: $WHEEL_FILE" | |
| # On Windows, git-bash may re-introduce C:/Program Files/Git/usr/bin into | |
| # PATH, causing Git's link.exe to shadow MSVC's when pip builds packages | |
| # from source (e.g. hmmlearn on Python 3.14 where no binary wheel exists). | |
| if [[ "$RUNNER_OS" == "Windows" ]]; then | |
| PATH=$(echo ":$PATH:" | tr ':' '\n' | grep -iv '/git/usr/bin' | grep -iv '\\Git\\usr\\bin' | grep . | tr '\n' ':' | sed 's/^://;s/:$//') | |
| export PATH | |
| fi | |
| # Try a full install first; if it fails (e.g. hmmlearn source build on | |
| # pre-release Python where no binary wheel exists), fall back to --no-deps | |
| # and install hmmlearn separately with --prefer-binary. | |
| if ! python -m pip install --force-reinstall "$WHEEL_FILE"; then | |
| echo "Full install failed; retrying with --no-deps + prefer-binary hmmlearn..." | |
| python -m pip install --force-reinstall --no-deps "$WHEEL_FILE" | |
| python -m pip install "hmmlearn>=0.2.0" --prefer-binary --retries 5 --timeout 120 \ | |
| || echo "INFO: hmmlearn binary wheel not yet available for this Python version (pre-release); skipping" | |
| fi | |
| # 检查安装的模块 - 使用 ASCII 字符 | |
| echo "=== Checking installed modules ===" | |
| python -c " | |
| import os | |
| import sys | |
| # Remove '' (cwd) from sys.path so Python finds the installed wheel in | |
| # site-packages rather than the source tree, which may contain stale | |
| # cpython-311 .so files built by the pre-cibuildwheel 'Build Cython | |
| # extensions' step and would cause ABI-mismatch segfaults on py3.12+. | |
| sys.path = [p for p in sys.path if p] | |
| import importlib.util | |
| # 设置标准输出编码为 UTF-8 以避免 Windows 编码问题 | |
| if sys.platform == 'win32': | |
| import io | |
| sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') | |
| def check_module(module_name): | |
| try: | |
| spec = importlib.util.find_spec(module_name) | |
| if spec: | |
| print(f'[OK] {module_name}: {spec.origin}') | |
| return True | |
| else: | |
| print(f'[FAIL] {module_name}: NOT FOUND') | |
| return False | |
| except Exception as e: | |
| print(f'[FAIL] {module_name}: ERROR - {e}') | |
| return False | |
| # 检查主包 | |
| check_module('sequenzo') | |
| check_module('sequenzo.dissimilarity_measures') | |
| check_module('sequenzo.dissimilarity_measures.utils') | |
| # 检查 Cython 模块 | |
| cython_modules = [ | |
| 'sequenzo.dissimilarity_measures.utils.get_sm_trate_substitution_cost_matrix', | |
| 'sequenzo.dissimilarity_measures.utils.seqconc', | |
| 'sequenzo.dissimilarity_measures.utils.seqdss', | |
| 'sequenzo.dissimilarity_measures.utils.seqdur', | |
| 'sequenzo.dissimilarity_measures.utils.seqlength' | |
| ] | |
| print('\\\\n=== Checking Cython modules ===') | |
| for module in cython_modules: | |
| check_module(module) | |
| # 检查包内容 | |
| print('\\\\n=== Package contents ===') | |
| try: | |
| import sequenzo | |
| pkg_path = os.path.dirname(sequenzo.__file__) | |
| print(f'Package path: {pkg_path}') | |
| # 列出所有文件 | |
| for root, dirs, files in os.walk(pkg_path): | |
| for file in files: | |
| if file.endswith(('.so', '.pyd', '.dylib')): | |
| print(f'Shared lib: {os.path.join(root, file)}') | |
| except Exception as e: | |
| print(f'Error examining package: {e}') | |
| " | |
| shell: bash | |
| - name: Run Integration Tests (Quickstart Workflow) | |
| run: | | |
| echo "=== Running integration tests based on quickstart tutorial ===" | |
| # 详细的调试信息 | |
| echo "Current Python: $(which python)" | |
| echo "Python version: $(python --version)" | |
| echo "Platform: $(python -c 'import platform; print(platform.platform())')" | |
| echo "Architecture: $(python -c 'import platform; print(platform.machine())')" | |
| # 更智能的轮子选择逻辑 | |
| python -c " | |
| import subprocess | |
| import sys | |
| import os | |
| import platform | |
| # 获取当前平台信息 | |
| current_platform = platform.system().lower() | |
| current_machine = platform.machine().lower() | |
| python_version = f'cp{sys.version_info.major}{sys.version_info.minor}' | |
| print(f'Current platform: {current_platform}') | |
| print(f'Current machine: {current_machine}') | |
| print(f'Python version tag: {python_version}') | |
| # 查找匹配的轮子文件 | |
| wheel_files = [f for f in os.listdir('dist') if f.endswith('.whl')] | |
| print(f'Available wheel files: {wheel_files}') | |
| # 优先选择与当前平台匹配的轮子 | |
| matching_wheels = [] | |
| for wheel_file in wheel_files: | |
| # 检查平台标签 | |
| platform_ok = False | |
| arch_ok = False | |
| if current_platform == 'darwin': | |
| # macOS 平台检查 | |
| if 'macosx' in wheel_file: | |
| platform_ok = True | |
| # 检查架构 | |
| if current_machine == 'arm64' and 'arm64' in wheel_file: | |
| arch_ok = True | |
| elif current_machine == 'x86_64' and 'x86_64' in wheel_file: | |
| arch_ok = True | |
| elif current_platform == 'linux': | |
| if 'linux' in wheel_file: | |
| platform_ok = True | |
| arch_ok = True # Linux 通常兼容性更好 | |
| elif current_platform == 'windows': | |
| if 'win' in wheel_file: | |
| platform_ok = True | |
| arch_ok = True | |
| if platform_ok and arch_ok and python_version in wheel_file: | |
| matching_wheels.append(wheel_file) | |
| print(f'Matching wheels: {matching_wheels}') | |
| if matching_wheels: | |
| # 选择最匹配的轮子 | |
| wheel_file = matching_wheels[0] | |
| print(f'Selected wheel for testing: {wheel_file}') | |
| # 卸载可能存在的旧版本 | |
| subprocess.run([sys.executable, '-m', 'pip', 'uninstall', '-y', 'sequenzo'], | |
| capture_output=True) | |
| # 安装新轮子 | |
| result = subprocess.run([ | |
| sys.executable, '-m', 'pip', 'install', '--force-reinstall', | |
| f'dist/{wheel_file}' | |
| ], capture_output=True, text=True) | |
| if result.returncode == 0: | |
| print('Wheel installed successfully') | |
| print('Installation output:', result.stdout) | |
| else: | |
| print(f'Wheel installation failed: {result.stderr}') | |
| # 尝试使用 --ignore-requires-python | |
| print('Trying with --ignore-requires-python flag...') | |
| subprocess.run([ | |
| sys.executable, '-m', 'pip', 'install', '--force-reinstall', | |
| '--ignore-requires-python', f'dist/{wheel_file}' | |
| ], check=True) | |
| else: | |
| print('ERROR: No matching wheel files found') | |
| sys.exit(1) | |
| " | |
| # 检查安装状态 | |
| echo "=== Checking installation ===" | |
| python -c " | |
| import sys | |
| import subprocess | |
| # 检查已安装的包 | |
| result = subprocess.run([sys.executable, '-m', 'pip', 'list'], | |
| capture_output=True, text=True) | |
| print('Installed packages:') | |
| print(result.stdout) | |
| # 检查 sequenzo 是否在列表中 | |
| if 'sequenzo' in result.stdout: | |
| print('✓ sequenzo found in pip list') | |
| else: | |
| print('✗ sequenzo NOT found in pip list') | |
| " | |
| # 详细的导入测试 | |
| echo "=== Testing sequenzo import ===" | |
| python -c " | |
| import sys | |
| import os | |
| print('Python path:') | |
| for p in sys.path: | |
| print(f' {p}') | |
| # 尝试找到 sequenzo 包 | |
| try: | |
| import importlib.util | |
| spec = importlib.util.find_spec('sequenzo') | |
| if spec: | |
| print(f'✓ Found sequenzo at: {spec.origin}') | |
| else: | |
| print('✗ sequenzo module not found in Python path') | |
| except Exception as e: | |
| print(f'Error finding sequenzo: {e}') | |
| # 尝试导入 | |
| try: | |
| import sequenzo | |
| print('✓ Successfully imported sequenzo') | |
| print(f'Sequenzo version: {getattr(sequenzo, \"__version__\", \"unknown\")}') | |
| print(f'Sequenzo location: {sequenzo.__file__}') | |
| except ImportError as e: | |
| print(f'✗ Failed to import sequenzo: {e}') | |
| # 打印详细的错误信息 | |
| import traceback | |
| traceback.print_exc() | |
| except Exception as e: | |
| print(f'✗ Error during import: {e}') | |
| import traceback | |
| traceback.print_exc() | |
| " | |
| # 如果导入成功,运行集成测试 | |
| echo "=== Running integration tests ===" | |
| if python -c "import sequenzo" 2>/dev/null; then | |
| echo "Sequenzo imported successfully, running integration tests..." | |
| # 安装 pytest 如果尚未安装 | |
| pip install pytest || echo "pytest installation failed, but continuing..." | |
| # 检查测试文件是否存在 | |
| if [ -f "tests/test_quickstart_integration.py" ]; then | |
| pytest tests/test_quickstart_integration.py -v -s || echo "Integration tests completed with warnings" | |
| else | |
| echo "Test file not found" | |
| fi | |
| else | |
| echo "ERROR: Cannot import sequenzo, skipping integration tests" | |
| # 退出并显示错误 | |
| exit 1 | |
| fi | |
| shell: bash | |
| - name: Set up Miniconda for Windows wheel smoke | |
| if: runner.os == 'Windows' | |
| uses: conda-incubator/setup-miniconda@v3 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| activate-environment: sequenzo-wheel-smoke | |
| auto-update-conda: true | |
| - name: Windows Conda wheel smoke test | |
| if: runner.os == 'Windows' | |
| shell: bash -el {0} | |
| run: | | |
| echo "=== Windows Conda wheel smoke test ===" | |
| conda info | |
| python -m pip install --upgrade pip | |
| echo "Installing $WHEEL into Conda environment" | |
| python - <<'PY' | |
| import glob | |
| import os | |
| import zipfile | |
| py_tag = "cp${{ matrix.python-version-nodot }}" | |
| wheels = sorted(glob.glob(f"dist/*{py_tag}*win_amd64.whl")) | |
| if not wheels: | |
| raise SystemExit(f"No Windows wheel found for {py_tag}") | |
| wheel = os.path.abspath(wheels[-1]) | |
| with zipfile.ZipFile(wheel) as zf: | |
| native = [ | |
| name | |
| for name in zf.namelist() | |
| if "core_distance_c_code" in name.replace("\\", "/") | |
| and name.lower().endswith((".pyd", ".so")) | |
| ] | |
| if not native: | |
| raise SystemExit( | |
| f"Wheel missing core_distance_c_code binary: {wheel}" | |
| ) | |
| print(f"[OK] wheel contains core_distance_c_code: {native[0]}") | |
| with open(os.path.join(os.environ["RUNNER_TEMP"], "sequenzo-wheel-smoke.env"), "w", encoding="utf-8") as fh: | |
| fh.write(f"WHEEL={wheel}\n") | |
| PY | |
| python -m pip install --force-reinstall "$(grep '^WHEEL=' "$RUNNER_TEMP/sequenzo-wheel-smoke.env" | cut -d= -f2-)" | |
| mkdir -p "$RUNNER_TEMP/sequenzo-wheel-smoke" | |
| cd "$RUNNER_TEMP/sequenzo-wheel-smoke" | |
| python - <<'PY' | |
| import importlib.util | |
| import os | |
| import sys | |
| workspace = os.path.abspath(os.environ.get("GITHUB_WORKSPACE", "")) | |
| sys.path = [ | |
| p | |
| for p in sys.path | |
| if p | |
| and (not workspace or os.path.abspath(p) != workspace) | |
| ] | |
| import numpy as np | |
| print("CONDA_PREFIX=" + os.environ.get("CONDA_PREFIX", "")) | |
| print("CWD=" + os.getcwd()) | |
| from sequenzo import SequenceData, get_distance_matrix, Cluster | |
| import sequenzo.utils.core_distance_operations.core_distance_c_code as core_distance_c_code | |
| from sequenzo.utils.core_distance_operations import weighted_inertia_contrib | |
| spec = importlib.util.find_spec( | |
| "sequenzo.utils.core_distance_operations.core_distance_c_code" | |
| ) | |
| if spec is None or not spec.origin: | |
| raise SystemExit( | |
| "core_distance_c_code not found after wheel install; " | |
| f"sys.path={sys.path!r}" | |
| ) | |
| print(f"[OK] core_distance_c_code loaded from: {spec.origin}") | |
| contrib = weighted_inertia_contrib( | |
| np.array([[0.0, 1.0], [1.0, 0.0]], dtype=np.float64), | |
| np.array([0, 1], dtype=np.int32), | |
| np.array([1.0, 1.0], dtype=np.float64), | |
| ) | |
| assert contrib.shape == (2,) | |
| print("[OK] Windows Conda wheel smoke test passed") | |
| PY | |
| - name: Upload wheels | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: wheels-${{ matrix.os }}-${{ matrix.python-version }} | |
| path: dist/ | |
| # 在所有 build matrix job 完成后,把所有分散的 wheels-* artifact 合并为一个 | |
| # all-wheels artifact,用户只需下载这一个 zip,pip 会自动挑选兼容的 wheel。 | |
| merge-wheels: | |
| name: Merge all wheels into one artifact | |
| needs: build | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Merge wheel artifacts | |
| uses: actions/upload-artifact/merge@v4 | |
| with: | |
| name: all-wheels | |
| pattern: wheels-* | |
| delete-merged: false |