Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 72 additions & 19 deletions .github/workflows/cicd_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,11 @@ on:
- dev
- main
- releasing/*
paths-ignore: # skip if only docs are modified
- '**.md'
- '**.rst'
- 'docs/**'
pull_request:
branches:
- dev
- main
- releasing/*
paths-ignore: # skip if only docs are modified
- '**.md'
- '**.rst'
- 'docs/**'

permissions:
contents: read
Expand Down Expand Up @@ -55,6 +47,53 @@ env:
# When support is dropped for a version it is important to update these as appropriate.

jobs:
# This job will set its output value `skip` to 2 if only documentation was changed, either documentation files or
# the docstrings of source files. If any source lines are changed, or non-documentation files are changed, the
# result is 0 which then triggers tests to run. This job works by using the compare_sources.py script to compare
# changed files to see if they differ in ways other than comments or docstrings.
test_skip:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.check-changes.outputs.skip }}
steps:
- uses: actions/checkout@v7
Comment thread
ericspod marked this conversation as resolved.
with:
fetch-depth: 2
- name: Set up Python ${{ env.PYTHON_VER1 }}
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VER1 }}
cache: 'pip'
- name: Check File Changes
id: check-changes
shell: bash
run: |
echo "skip=1" >> $GITHUB_OUTPUT

mkdir -p /tmp/check_changes
if ! git diff --name-only HEAD^..HEAD ':!docs' > /tmp/check_changes/changed_files
then
echo "skip=0" >> "$GITHUB_OUTPUT"
exit 0
fi

while read i
do
echo "Checking for changes: $i"
if ! git show HEAD^:$i > /tmp/check_changes/$(basename $i)
then
echo "New file found, or 'git show' failed, running tests."
elif ! python monai/utils/compare_sources.py $i /tmp/check_changes/$(basename $i)
then
echo "Change found, running tests."
else
continue
fi

echo "skip=0" >> $GITHUB_OUTPUT
break
done < /tmp/check_changes/changed_files

static-checks: # Perform static type and other checks using runtests.sh
runs-on: ubuntu-latest
strategy:
Expand Down Expand Up @@ -87,6 +126,7 @@ jobs:

min-dep: # Test with minumum dependencies installed for different OS, Python, and PyTorch combinations
runs-on: ${{ matrix.os }}
needs: test_skip
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -123,25 +163,29 @@ jobs:

timeout-minutes: 40
steps:
- if: runner.os == 'Linux'
name: Clean unused tools
- name: Clean unused tools
if: runner.os == 'Linux' && needs.test_skip.outputs.skip == 0
run: |
find /opt/hostedtoolcache/* -maxdepth 0 ! -name 'Python' -exec rm -rf {} \;
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc /usr/local/.ghcup
sudo docker system prune -f
- uses: actions/checkout@v7
if: needs.test_skip.outputs.skip == 0
- name: Set up Python ${{ matrix.python-version }}
if: needs.test_skip.outputs.skip == 0
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Prepare pip wheel
if: needs.test_skip.outputs.skip == 0
run: |
which python
python -m pip install --upgrade pip wheel tomli
- name: Install the minimum dependencies
if: needs.test_skip.outputs.skip == 0
run: |
# min. requirements
python -m pip install torch==${{ matrix.pytorch-version }}
Expand All @@ -150,6 +194,7 @@ jobs:
python -m pip list
shell: bash
- name: Run quick tests
if: needs.test_skip.outputs.skip == 0
run: |
nvidia-smi || true
python monai/config/check_env.py --env --monai
Expand All @@ -168,71 +213,77 @@ jobs:
matrix:
os: [windows-latest, ubuntu-latest, macOS-latest] # macOS-latest is very slow (#8864), testing install only
timeout-minutes: 120
needs: test_skip
env:
QUICKTEST: True
steps:
- if: runner.os == 'Linux'
name: Clean unused tools
- name: Clean unused tools
if: runner.os == 'Linux' && needs.test_skip.outputs.skip == 0
run: |
find /opt/hostedtoolcache/* -maxdepth 0 ! -name 'Python' -exec rm -rf {} \;
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc /usr/local/.ghcup
sudo docker system prune -f
- if: runner.os == 'windows'
name: Config pagefile (Windows only)
- name: Config pagefile (Windows only)
if: runner.os == 'windows' && needs.test_skip.outputs.skip == 0
uses: al-cheb/configure-pagefile-action@v1.5
with:
minimum-size: 8GB
maximum-size: 16GB
disk-root: "D:"
- uses: actions/checkout@v7
if: needs.test_skip.outputs.skip == 0
- name: Set up Python ${{ env.PYTHON_VER1 }}
if: needs.test_skip.outputs.skip == 0
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VER1 }}
cache: 'pip'
- name: Prepare pip wheel
if: needs.test_skip.outputs.skip == 0
run: |
which python
python -m pip install --upgrade pip wheel
- if: runner.os == 'windows'
- if: runner.os == 'windows' && needs.test_skip.outputs.skip == 0
name: Install torch cpu from pytorch.org (Windows only)
run: |
python -m pip install torch==${WIN_FULLDEP_VER} torchvision==${WIN_FULLDEP_VISION_VER}+cpu --index-url https://download.pytorch.org/whl/cpu
shell: bash
- if: runner.os != 'windows'
- if: runner.os != 'windows' && needs.test_skip.outputs.skip == 0
name: Install torch gpu
run: |
# install the lowest version of PyTorch supported
python -m pip install torch==${PYTORCH_VER1} torchvision==${TORCHVISION_VER1}
shell: bash
- if: runner.os == 'Linux'
- if: runner.os == 'Linux' && needs.test_skip.outputs.skip == 0
name: Install itk pre-release (Linux only)
run: |
python -m pip install --pre -U itk
shell: bash
- name: Install the complete dependencies
if: needs.test_skip.outputs.skip == 0
run: |
python -m pip install --user --upgrade pip wheel tomli
python monai/config/print_dependencies.py build-system | xargs pip install --no-build-isolation
python -m pip install --no-build-isolation .[all,testing]
python -m pip list
shell: bash
- name: Run compiled (${{ runner.os }})
if: needs.test_skip.outputs.skip == 0
run: |
python -m pip uninstall -y monai
BUILD_MONAI=1 python -m pip install --no-build-isolation -e . # compile the cpp extensions
python -c 'import monai._C' > /dev/null
nvidia-smi || true
python monai/config/check_env.py --env --monai
shell: bash
- if: runner.os != 'macOS'
- if: runner.os != 'macOS' && needs.test_skip.outputs.skip == 0
name: Run full tests
run: |
python -m unittest -v
shell: bash
- if: runner.os == 'macOS'
- if: runner.os == 'macOS' && needs.test_skip.outputs.skip == 0
name: Run min tests
run: |
# TODO: enable large range of macOS tests which don't take a very long time
Expand All @@ -252,6 +303,8 @@ jobs:
# ``.github/workflows/pythonapp-hyena-gpu.yml``.
runs-on: ubuntu-latest
timeout-minutes: 30
needs: test_skip
if: needs.test_skip.outputs.skip == 0
steps:
- name: Clean unused tools
run: |
Expand Down
5 changes: 5 additions & 0 deletions docs/source/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,8 @@ Safe Evaluation
---------------
.. automodule:: monai.utils.safeeval
:members:

Compare Sources
---------------
.. automodule:: monai.utils.compare_sources
:members:
1 change: 1 addition & 0 deletions monai/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

from .compare_sources import SKIP_EXTS, files_considered_equal, sources_equal
from .component_store import ComponentStore
from .decorators import MethodReplacer, RestartGenerator
from .deprecate_utils import DeprecatedError, deprecated, deprecated_arg, deprecated_arg_default
Expand Down
107 changes: 107 additions & 0 deletions monai/utils/compare_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Copyright (c) MONAI Consortium
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
This module is used in Github Actions to determine if a Python source file has been modified in a PR.
"""

from __future__ import annotations

import ast
import os
import sys
from os.path import splitext

PathLike = str | os.PathLike # needs to be duplicated here to avoid importing anything from MONAI inside actions

SKIP_EXTS = (".md", ".rst")


class RemoveDocstrings(ast.NodeTransformer):
"""
Strips docstrings from source files so that they aren't used for comparing ASTs.
"""

def visit(self, node: ast.AST) -> ast.AST:
try:
# remove docstrings from the files
if ast.get_docstring(node) is not None:
del node.body[0]
except TypeError:
pass
return super().visit(node)


def sources_equal(src1: str, src2: str) -> bool:
"""
Compare Python source texts at the AST level without docstrings or comments. If two texts are equal except for
changes to docstrings or comments, they will be considered equal by this function. Any other changes will appear
as differences in the AST representations, which are compared inefficiently by comparing tree string dumps.

Args:
src1: first Python source text.
src2: second Python source text.

Returns:
True if the texts `src1` and `src2` are equal with docstrings and comments removed, False otherwise.
"""
remdoc = RemoveDocstrings()

m1 = ast.parse(src1)
m2 = ast.parse(src2)
remdoc.visit(m1)
remdoc.visit(m2)

list1 = list(ast.walk(m1))
list2 = list(ast.walk(m2))

if len(list1) != len(list2) or any(type(n1) is not type(n2) for n1, n2 in zip(list1, list2)):
return False

return ast.dump(m1) == ast.dump(m2)


def files_considered_equal(file1: PathLike, file2: PathLike) -> bool:
"""
Returns True if the files are considered equal, that is they are doc files or differ only in docstrings or comments.

Args:
file1: first file path to compare.
file2: second file path to compare.

Returns:
True if the files have the same extension, and either this extension is in `SKIP_EXTS`, or is ".py" with the
files having the same content according to `sources_equal`. If the extensions differ, or are both ".py" but the
file contents differ, returns False.
"""
_, ext1 = splitext(str(file1))
_, ext2 = splitext(str(file2))

# if extensions aren't equal then definitely different (which shouldn't happen anyway)
if ext1 != ext2:
return False

# if extensions are an ignored type, ie. docs, don't compare at all
if ext1 in SKIP_EXTS:
return True

# if not Python source files, they aren't doc files at this point so assume different
if ext1 != ".py":
return False

# compare the actual parsed contents of the source files
with open(file1) as o1, open(file2) as o2:
return sources_equal(o1.read(), o2.read())


if __name__ == "__main__":
_, file1, file2 = sys.argv
sys.exit(0 if files_considered_equal(file1, file2) else 1)
Loading
Loading