Build Wheels for Sequenzo #307
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 source so delocate can bundle a runtime that matches | |
| # the wheel's minimum macOS version (Homebrew libomp often requires 14+). | |
| - 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 | |
| # 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 SEQUENZO_LIBOMP_PREFIX SEQUENZO_ENABLE_OPENMP MACOSX_DEPLOYMENT_TARGET" | |
| # 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" | |
| # 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 "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" | |
| python -m pip install --upgrade pip | |
| python -m pip install delvewheel | |
| CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: | | |
| set -euo pipefail | |
| echo "=== Repairing Windows wheel with delvewheel ===" | |
| echo "Wheel: {wheel}" | |
| echo "Dest: {dest_dir}" | |
| LLVM_OMP_BIN="" | |
| # vswhere.exe path — use Windows-style separators and the canonical env var name. | |
| VSWHERE="$PROGRAMFILES(X86)/Microsoft Visual Studio/Installer/vswhere.exe" | |
| # Git-Bash on Windows sometimes exposes PROGRAMFILES_X86 instead; fall back to it. | |
| if [ ! -f "$VSWHERE" ] && [ -n "${PROGRAMFILES_X86:-}" ]; then | |
| VSWHERE="$PROGRAMFILES_X86/Microsoft Visual Studio/Installer/vswhere.exe" | |
| fi | |
| # Last-resort hard-coded path used on GitHub-hosted runners. | |
| if [ ! -f "$VSWHERE" ]; then | |
| VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" | |
| fi | |
| if [ -f "$VSWHERE" ]; then | |
| echo "Using vswhere: $VSWHERE" | |
| # -find accepts glob patterns; use backslash path separators (Windows convention). | |
| OMP_DLL="$("$VSWHERE" -latest -products '*' \ | |
| -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 \ | |
| -find 'VC\Tools\Llvm\x64\bin\libomp140.x86_64.dll' 2>/dev/null | head -1 || true)" | |
| if [ -n "$OMP_DLL" ] && [ -f "$OMP_DLL" ]; then | |
| LLVM_OMP_BIN="$(dirname "$OMP_DLL")" | |
| echo "Found libomp140.x86_64.dll via vswhere: $OMP_DLL" | |
| else | |
| echo "vswhere: libomp140.x86_64.dll not found via -find; trying glob fallback..." | |
| # Broad glob fallback: walk all MSVC LLVM bin dirs for the DLL. | |
| for candidate in \ | |
| "C:/Program Files/Microsoft Visual Studio/2022/Enterprise/VC/Tools/Llvm/x64/bin/libomp140.x86_64.dll" \ | |
| "C:/Program Files/Microsoft Visual Studio/2022/Community/VC/Tools/Llvm/x64/bin/libomp140.x86_64.dll" \ | |
| "C:/Program Files/LLVM/bin/libomp.dll" \ | |
| "C:/Program Files/LLVM/bin/libomp140.x86_64.dll"; do | |
| if [ -f "$candidate" ]; then | |
| LLVM_OMP_BIN="$(dirname "$candidate")" | |
| echo "Found OpenMP DLL via fallback path: $candidate" | |
| break | |
| fi | |
| done | |
| fi | |
| else | |
| echo "WARNING: vswhere.exe not found; skipping OpenMP DLL search" | |
| fi | |
| if [ -n "$LLVM_OMP_BIN" ]; then | |
| echo "Bundling OpenMP from: $LLVM_OMP_BIN" | |
| delvewheel repair --add-path "$LLVM_OMP_BIN" -w "{dest_dir}" "{wheel}" || true | |
| else | |
| echo "WARNING: MSVC LLVM OpenMP not found; running delvewheel without --add-path (OpenMP may be missing from wheel)" | |
| delvewheel repair -w "{dest_dir}" "{wheel}" || true | |
| fi | |
| # repair_windows_wheel.py is the safety net: if delvewheel emitted a | |
| # repaired wheel it confirms it; otherwise it copies the built wheel | |
| # as-is so cibuildwheel always finds a wheel in dest_dir. | |
| python "{project}/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) | |
| CIBW_TEST_COMMAND_MACOS: | | |
| # 根据架构设置正确的库路径 | |
| CURRENT_ARCH=$(uname -m) | |
| echo "=== Testing on $CURRENT_ARCH ===" | |
| # 检测架构并设置正确的 libomp 路径 | |
| if [ "$CURRENT_ARCH" == "x86_64" ]; then | |
| # Intel macOS | |
| EXPECTED_LIBOMP_PATH="/usr/local/opt/libomp/lib" | |
| else | |
| # Apple Silicon macOS | |
| EXPECTED_LIBOMP_PATH="/opt/homebrew/opt/libomp/lib" | |
| fi | |
| # 如果预期路径存在,使用它;否则使用环境变量中的路径 | |
| if [ -d "$EXPECTED_LIBOMP_PATH" ]; then | |
| ACTUAL_LIBOMP_PATH="$EXPECTED_LIBOMP_PATH" | |
| echo "Using architecture-specific libomp path: $ACTUAL_LIBOMP_PATH" | |
| else | |
| ACTUAL_LIBOMP_PATH="$LIBOMP_PATH" | |
| echo "Using environment LIBOMP_PATH: $ACTUAL_LIBOMP_PATH" | |
| fi | |
| # 设置库路径 | |
| export DYLD_LIBRARY_PATH="$ACTUAL_LIBOMP_PATH:$DYLD_LIBRARY_PATH" | |
| export DYLD_FALLBACK_LIBRARY_PATH="$ACTUAL_LIBOMP_PATH:$DYLD_FALLBACK_LIBRARY_PATH" | |
| echo "Python version: $(python --version)" | |
| echo "Final DYLD_LIBRARY_PATH: $DYLD_LIBRARY_PATH" | |
| # 验证 libomp 文件 | |
| if [ -d "$ACTUAL_LIBOMP_PATH" ]; then | |
| echo "libomp directory contents:" | |
| ls -la "$ACTUAL_LIBOMP_PATH" || echo "Cannot list libomp directory" | |
| if [ -f "$ACTUAL_LIBOMP_PATH/libomp.dylib" ]; then | |
| echo "libomp.dylib architecture:" | |
| file "$ACTUAL_LIBOMP_PATH/libomp.dylib" || echo "Cannot check libomp architecture" | |
| else | |
| echo "WARNING: libomp.dylib not found at $ACTUAL_LIBOMP_PATH/libomp.dylib" | |
| fi | |
| else | |
| echo "ERROR: libomp directory not found: $ACTUAL_LIBOMP_PATH" | |
| fi | |
| # Check test environment | |
| echo "Test architecture: $(uname -m)" | |
| echo "Python version: $(python --version)" | |
| # Find and inspect the installed wheel | |
| echo "" | |
| echo "=== Checking installed sequenzo package ===" | |
| python -c "import sequenzo, os; print(f'Sequenzo location: {sequenzo.__file__}')" || true | |
| # Try to find bundled dylibs | |
| python -c " | |
| import sequenzo, os, glob | |
| pkg_dir = os.path.dirname(sequenzo.__file__) | |
| dylibs_dir = os.path.join(pkg_dir, '.dylibs') | |
| if os.path.exists(dylibs_dir): | |
| print(f'[OK] .dylibs directory found: {dylibs_dir}') | |
| libs = glob.glob(os.path.join(dylibs_dir, '*.dylib')) | |
| if libs: | |
| print(f'Bundled libraries: {libs}') | |
| else: | |
| print('[WARNING] .dylibs directory is empty') | |
| else: | |
| print('[WARNING] .dylibs directory not found - wheel may not be properly bundled') | |
| " || true | |
| echo "" | |
| echo "=== Testing Sequenzo import ===" | |
| python - <<'PY' | |
| import importlib.util | |
| import sys | |
| import os | |
| import numpy as np | |
| # Try to import sequenzo | |
| try: | |
| import sequenzo | |
| print('[OK] Sequenzo import successful') | |
| except ImportError as e: | |
| print(f'[ERROR] Failed to import sequenzo: {e}') | |
| sys.exit(1) | |
| # Check C++ clustering extensions (clustering) | |
| mod = importlib.util.find_spec('sequenzo.clustering.clustering_c_code') | |
| if mod: | |
| print('[OK] Clustering C++ extensions module spec found') | |
| try: | |
| import sequenzo.clustering.clustering_c_code | |
| print('[OK] Clustering C++ extensions loaded successfully') | |
| except ImportError as e: | |
| print(f'[WARNING] Clustering C++ extensions found but failed to load: {e}') | |
| if 'kmpc' in str(e) or 'omp' in str(e): | |
| print('[INFO] This appears to be an OpenMP linking issue') | |
| print('[INFO] The wheel may need libomp to be installed on the system') | |
| else: | |
| print('[WARNING] Clustering C++ extensions not found') | |
| # Check dissimilarity measures | |
| try: | |
| from sequenzo.dissimilarity_measures import c_code | |
| print('[OK] Dissimilarity measures C++ extensions (c_code) loaded successfully') | |
| # Test specific C++ classes | |
| for class_name in ['OMdistance', 'OMspellDistance', 'DHDdistance', 'LCPdistance', 'dist2matrix']: | |
| if hasattr(c_code, class_name): | |
| print(f'[OK] {class_name} class available') | |
| except ImportError as e: | |
| print(f'[WARNING] Dissimilarity measures C++ extensions (c_code) failed to load: {e}') | |
| # Check Cython modules | |
| 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'[WARNING] 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'[WARNING] 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 | |
| try: | |
| Z_vec = linkage_vector(test_data, method='complete', 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('') | |
| print('[INFO] Test completed - some warnings are expected for optional dependencies') | |
| 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 | |
| try: | |
| Z_vec = linkage_vector(test_data, method='complete', 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 test command using python -c with exec for multiline code | |
| CIBW_TEST_COMMAND_WINDOWS: | | |
| echo "Testing Sequenzo wheel on Windows" | |
| echo "" > test_sequenzo_ci.py | |
| Add-Content test_sequenzo_ci.py @" | |
| import importlib.util, 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('[OK] Dissimilarity measures C++ extensions (c_code) loaded successfully') | |
| for name in ['OMdistance', 'OMspellDistance', 'DHDdistance', 'LCPdistance', 'dist2matrix']: | |
| if hasattr(c_code, name): | |
| print(f'[OK] {name} class available') | |
| except ImportError as e: | |
| print(f'ERROR: Failed to import c_code: {e}') | |
| print('') | |
| print('=== Testing core distance and Cluster import path ===') | |
| try: | |
| import sequenzo.utils.core_distance_operations.core_distance_c_code as core_distance_c_code | |
| print('[OK] core_distance_c_code loaded successfully') | |
| except ImportError as e: | |
| print(f'[WARNING] core_distance_c_code failed to load: {e}') | |
| try: | |
| from sequenzo.utils.core_distance_operations import weighted_inertia_contrib | |
| test_dist = np.array([[0.0, 1.0], [1.0, 0.0]], dtype=np.float64) | |
| contrib = weighted_inertia_contrib( | |
| test_dist, | |
| np.array([0, 1], dtype=np.int32), | |
| np.array([1.0, 1.0], dtype=np.float64), | |
| ) | |
| assert contrib.shape == (2,) | |
| print('[OK] weighted_inertia_contrib works') | |
| except Exception as e: | |
| print(f'[ERROR] weighted_inertia_contrib failed: {e}') | |
| sys.exit(1) | |
| try: | |
| from sequenzo import Cluster | |
| print('[OK] Cluster import successful') | |
| except ImportError as e: | |
| print(f'[ERROR] Cluster import failed: {e}') | |
| sys.exit(1) | |
| # 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 | |
| try: | |
| Z_vec = linkage_vector(test_data, method='complete', 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') | |
| # 为 NumPy 2.0 设置兼容性 | |
| if sys.version_info >= (3, 12): | |
| try: | |
| np._import_array() | |
| print('[OK] NumPy array imported for NumPy 2.0 compatibility') | |
| except Exception as e: | |
| print(f'[INFO] NumPy array import: {e}') | |
| print('[INFO] All tests completed') | |
| "@ | |
| python test_sequenzo_ci.py | |
| - name: Show dist content | |
| run: ls -lah dist/ | |
| 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: Upload wheels | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: wheels-${{ matrix.os }}-${{ matrix.python-version }} | |
| path: dist/ |