/ .github / workflows / testing.yml
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"