testing.yml
1 # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 4 name: Testing 5 6 on: 7 push: 8 branches: 9 - master 10 - development 11 pull_request: 12 branches: 13 - master 14 - development 15 16 # Cancel any in-progress or queued Testing run on the same source 17 # branch when a new push arrives. Collapses duplicate push + PR runs 18 # from the same commit into a single active run, so rapid iteration 19 # does not stack up stale jobs. 20 # 21 # The group key uses github.head_ref (populated on PRs with the 22 # source branch) falling back to github.ref_name (populated on pushes 23 # with the branch short name), so a push to development and a PR 24 # with development as the source end up in the same group. The 25 # pull_request.head.repo.full_name fallback namespaces the group by 26 # fork identity, so two different forks with the same branch name 27 # do not collide. 28 # 29 # testing-cron.yml intentionally does not use concurrency because 30 # each scheduled cron trigger is an independent run. 31 concurrency: 32 group: ${{ github.workflow }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} 33 cancel-in-progress: true 34 35 jobs: 36 build: 37 runs-on: ${{ matrix.os }} 38 timeout-minutes: 60 39 40 strategy: 41 fail-fast: false 42 matrix: 43 os: [ubuntu-latest, windows-latest] 44 python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 45 # macOS is pinned to a single Python version because Apple Silicon 46 # runners were intermittently not finishing the non-torch test suite 47 # within the job timeout on 3.10-3.13 even though 3.9 passes 48 # reliably. Ubuntu and Windows cover the full Python matrix, so the 49 # single macOS cell is a smoke-test canary for Apple Silicon rather 50 # than full per-version coverage. 51 # TODO: re-enable 3.10-3.13 on macOS once the Apple Silicon slowness 52 # is understood (profile via pytest --durations=20 first). 53 include: 54 - os: macos-latest 55 python-version: "3.9" 56 57 steps: 58 - uses: actions/checkout@v4 59 - name: Python ${{ matrix.python-version }} 60 uses: actions/setup-python@v5 61 with: 62 python-version: ${{ matrix.python-version }} 63 64 - name: Install libomp for macOS 65 if: startsWith(matrix.os, 'macos') 66 run: brew install libomp 67 68 - name: Install dependencies (non-macOS, full deps including torch) 69 if: ${{ !startsWith(matrix.os, 'macos') }} 70 run: | 71 python -m pip install --upgrade pip 72 pip install -r docs/requirements.txt 73 pip install pytest 74 pip install coverage 75 pip install coveralls 76 77 # macOS CI deliberately skips torch and torch_geometric because of 78 # the upstream PyTorch NNPACK slowdown on Apple Silicon 79 # (https://github.com/pytorch/pytorch/issues/107534). Conv-heavy 80 # torch tests run ~36x slower on Apple Silicon and exhaust any 81 # reasonable job timeout. Torch-dependent tests are auto-skipped 82 # by pyod/test/conftest.py when torch is absent. 83 # TODO: remove this macOS-specific step and the conftest shim once 84 # upstream PyTorch fixes NNPACK on ARM and a fixed wheel is released. 85 - name: Install dependencies (macOS, skip torch and torch_geometric) 86 if: startsWith(matrix.os, 'macos') 87 run: | 88 python -m pip install --upgrade pip 89 grep -vE '^(torch|torch_geometric)$' docs/requirements.txt > /tmp/mac-reqs.txt 90 pip install -r /tmp/mac-reqs.txt 91 pip install pytest 92 pip install coverage 93 pip install coveralls 94 95 - name: Test with pytest 96 run: | 97 coverage run --source=pyod -m pytest 98 99 - name: Coverage report 100 shell: bash 101 continue-on-error: true 102 env: 103 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 104 COVERALLS_SERVICE_NAME: github-actions 105 run: | 106 if ! coveralls --service=github; then 107 echo "Coveralls upload failed; retrying once in 15s for best effort." 108 sleep 15 109 coveralls --service=github || true 110 fi 111 112 packaging-smoke-test: 113 name: packaging smoke test (sdist + wheel from a clean build) 114 runs-on: ubuntu-latest 115 timeout-minutes: 15 116 steps: 117 - uses: actions/checkout@v4 118 119 - name: Set up Python 3.11 120 uses: actions/setup-python@v5 121 with: 122 python-version: '3.11' 123 124 - name: Install build tooling 125 run: python -m pip install --upgrade pip build twine 126 127 - name: Build sdist and wheel 128 run: python -m build 129 130 - name: Inspect sdist contents 131 run: | 132 set -euo pipefail 133 sdist=$(ls dist/*.tar.gz | head -1) 134 echo "Inspecting $sdist" 135 tar tzf "$sdist" > /tmp/sdist-listing.txt 136 knowledge_count=$(grep -c 'pyod/utils/knowledge/.*\.json' /tmp/sdist-listing.txt || true) 137 analysis_count=$(grep -c 'pyod/utils/model_analysis_jsons/.*\.json' /tmp/sdist-listing.txt || true) 138 echo "knowledge JSONs in sdist: $knowledge_count" 139 echo "model_analysis JSONs in sdist: $analysis_count" 140 test "$knowledge_count" -ge 4 || { echo "ERROR: expected >=4 knowledge JSONs in sdist"; exit 1; } 141 test "$analysis_count" -ge 10 || { echo "ERROR: expected >=10 model_analysis JSONs in sdist"; exit 1; } 142 143 - name: Inspect wheel contents 144 run: | 145 set -euo pipefail 146 wheel=$(ls dist/*.whl | head -1) 147 echo "Inspecting $wheel" 148 python -m zipfile -l "$wheel" > /tmp/wheel-listing.txt 149 knowledge_count=$(grep -c 'pyod/utils/knowledge/.*\.json' /tmp/wheel-listing.txt || true) 150 analysis_count=$(grep -c 'pyod/utils/model_analysis_jsons/.*\.json' /tmp/wheel-listing.txt || true) 151 skill_count=$(grep -c 'pyod/skills/od_expert/SKILL\.md' /tmp/wheel-listing.txt || true) 152 test_count=$(grep -c 'pyod/test/' /tmp/wheel-listing.txt || true) 153 docs_count=$(grep -cE '^docs/' /tmp/wheel-listing.txt || true) 154 echo "knowledge JSONs in wheel: $knowledge_count" 155 echo "model_analysis JSONs in wheel: $analysis_count" 156 echo "od_expert SKILL.md in wheel: $skill_count" 157 echo "test files in wheel: $test_count" 158 echo "docs files in wheel: $docs_count" 159 test "$knowledge_count" -ge 4 || { echo "ERROR: expected >=4 knowledge JSONs in wheel"; exit 1; } 160 test "$analysis_count" -ge 10 || { echo "ERROR: expected >=10 model_analysis JSONs in wheel"; exit 1; } 161 test "$skill_count" -eq 1 || { echo "ERROR: expected exactly 1 od_expert SKILL.md in wheel"; exit 1; } 162 test "$test_count" -eq 0 || { echo "ERROR: wheel contains $test_count pyod/test files; should be 0"; exit 1; } 163 test "$docs_count" -eq 0 || { echo "ERROR: wheel contains $docs_count docs/ files; should be 0"; exit 1; } 164 165 - name: Validate metadata with twine 166 run: twine check dist/* 167 168 - name: Fresh-venv install from sdist and import smoke test 169 run: | 170 set -euo pipefail 171 python -m venv /tmp/pyod-smoke 172 /tmp/pyod-smoke/bin/python -m pip install --upgrade pip 173 /tmp/pyod-smoke/bin/python -m pip install dist/*.tar.gz 174 /tmp/pyod-smoke/bin/python - <<'PY' 175 import pyod 176 from pyod.models.iforest import IForest 177 from pyod.utils.ad_engine import ADEngine 178 179 print("pyod version:", pyod.__version__) 180 assert pyod.__version__.startswith("3."), f"unexpected version {pyod.__version__}" 181 182 engine = ADEngine() 183 detectors = engine.list_detectors() 184 assert len(detectors) > 0, "ADEngine knowledge base loaded 0 detectors" 185 print("ADEngine detectors loaded:", len(detectors)) 186 187 import numpy as np 188 rng = np.random.default_rng(42) 189 X = rng.standard_normal((50, 5)) 190 clf = IForest(contamination=0.1, random_state=42) 191 clf.fit(X) 192 print("IForest fit OK; decision_scores_ shape:", clf.decision_scores_.shape) 193 PY 194 195 - name: Verify unified pyod CLI end-to-end 196 run: | 197 set -euo pipefail 198 /tmp/pyod-smoke/bin/pyod --help 199 /tmp/pyod-smoke/bin/pyod info 200 /tmp/pyod-smoke/bin/pyod install skill --help 201 /tmp/pyod-smoke/bin/pyod install skill --list 202 rm -rf /tmp/pyod-cli-skill-test 203 /tmp/pyod-smoke/bin/pyod install skill --target /tmp/pyod-cli-skill-test 204 test -f /tmp/pyod-cli-skill-test/od-expert/SKILL.md \ 205 || { echo "ERROR: pyod install skill did not create SKILL.md"; exit 1; } 206 # v3.2.0: verify references/ subdir was copied alongside SKILL.md 207 test -d /tmp/pyod-cli-skill-test/od-expert/references \ 208 || { echo "ERROR: references/ subdir not installed"; exit 1; } 209 test -f /tmp/pyod-cli-skill-test/od-expert/references/workflow.md \ 210 || { echo "ERROR: references/workflow.md not installed"; exit 1; } 211 test -f /tmp/pyod-cli-skill-test/od-expert/references/pitfalls.md \ 212 || { echo "ERROR: references/pitfalls.md not installed"; exit 1; } 213 test -f /tmp/pyod-cli-skill-test/od-expert/references/tabular.md \ 214 || { echo "ERROR: references/tabular.md not installed"; exit 1; } 215 test -f /tmp/pyod-cli-skill-test/od-expert/references/time_series.md \ 216 || { echo "ERROR: references/time_series.md not installed"; exit 1; } 217 test -f /tmp/pyod-cli-skill-test/od-expert/references/graph.md \ 218 || { echo "ERROR: references/graph.md not installed"; exit 1; } 219 test -f /tmp/pyod-cli-skill-test/od-expert/references/text_image.md \ 220 || { echo "ERROR: references/text_image.md not installed"; exit 1; } 221 # v3.2.0: __pycache__ and __init__.py must NOT be installed 222 test ! -e /tmp/pyod-cli-skill-test/od-expert/__pycache__ \ 223 || { echo "ERROR: __pycache__ leaked into install"; exit 1; } 224 test ! -e /tmp/pyod-cli-skill-test/od-expert/__init__.py \ 225 || { echo "ERROR: __init__.py leaked into install"; exit 1; } 226 # Regression guard: `import pyod.mcp_server` in a smoke venv 227 # that deliberately does NOT have pyod[mcp] must not sys.exit. 228 /tmp/pyod-smoke/bin/python -c "import importlib.util; assert importlib.util.find_spec('mcp') is None, 'smoke venv unexpectedly has mcp'" 229 /tmp/pyod-smoke/bin/python -c "import pyod.mcp_server" 230 # Canonical-name assertion: underscore input → hyphen output 231 rm -rf /tmp/pyod-cli-underscore-test 232 /tmp/pyod-smoke/bin/pyod install skill --skill od_expert \ 233 --target /tmp/pyod-cli-underscore-test \ 234 | tee /tmp/pyod-cli-underscore.log 235 grep -q "Installed od-expert skill" /tmp/pyod-cli-underscore.log \ 236 || { echo "ERROR: canonical name not used in output"; exit 1; } 237 test -f /tmp/pyod-cli-underscore-test/od-expert/SKILL.md 238 echo "unified pyod CLI works end-to-end"