diff --git a/.github/workflows/cicd_tests.yml b/.github/workflows/cicd_tests.yml index d5cb03a33f..28a420d8c8 100644 --- a/.github/workflows/cicd_tests.yml +++ b/.github/workflows/cicd_tests.yml @@ -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 @@ -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 + 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: @@ -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: @@ -123,8 +163,8 @@ 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 @@ -132,16 +172,20 @@ jobs: 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 }} @@ -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 @@ -168,51 +213,56 @@ 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 @@ -220,6 +270,7 @@ jobs: 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 @@ -227,12 +278,12 @@ jobs: 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 @@ -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: | diff --git a/docs/source/utils.rst b/docs/source/utils.rst index 958d27337e..ef4bcc3670 100644 --- a/docs/source/utils.rst +++ b/docs/source/utils.rst @@ -85,3 +85,8 @@ Safe Evaluation --------------- .. automodule:: monai.utils.safeeval :members: + +Compare Sources +--------------- +.. automodule:: monai.utils.compare_sources + :members: diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 944f34aab7..f844789cd7 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -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 diff --git a/monai/utils/compare_sources.py b/monai/utils/compare_sources.py new file mode 100644 index 0000000000..f0e8bd9e41 --- /dev/null +++ b/monai/utils/compare_sources.py @@ -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) diff --git a/tests/utils/test_compare_sources.py b/tests/utils/test_compare_sources.py new file mode 100644 index 0000000000..2bbb8db0fc --- /dev/null +++ b/tests/utils/test_compare_sources.py @@ -0,0 +1,116 @@ +# 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. + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from parameterized import parameterized + +from monai.utils import SKIP_EXTS, files_considered_equal, sources_equal + +text1 = """ +def foo(arg1, arg2): + ''' + Compute the sum of arg1 and arg2 and return it. + ''' + return arg1 + arg2 +""" + +text2 = """ + +def foo(arg1, arg2): + ''' + Add arg1 and arg2 together and return it. + ''' + return arg1 + arg2 + +""" + +text3 = "def foo(arg1, arg2):return arg1 + arg2" + +text4 = "def bar(arg1, arg2):return arg1 + arg2" + +text5 = """ +class Foo: + def method(self,arg1): + '''Returns arg1.''' + return arg1 +""" + +text6 = """ +''' +This is a module containing only a docstring. +''' +""" + +text7 = """ +''' +So is this but it's different. +''' +""" + +TEXT_EQUAL_PAIRS = [(text1, text2), (text1, text3), (text2, text3), (text6, text7), (text6, ""), ("", "")] + +TEXT_UNEQUAL_PAIRS = [(text1, text4), (text2, text4), (text3, text4), (text1, text5), (text1, "")] + + +class TestSourcesEqual(unittest.TestCase): + @parameterized.expand(TEXT_EQUAL_PAIRS) + def test_equal_texts(self, t1, t2): + self.assertTrue(sources_equal(t1, t2)) + self.assertTrue(sources_equal(t2, t1)) + + @parameterized.expand(TEXT_UNEQUAL_PAIRS) + def test_unequal_texts(self, t1, t2): + self.assertFalse(sources_equal(t1, t2)) + self.assertFalse(sources_equal(t2, t1)) + + +class TestFilesEqual(unittest.TestCase): + def test_equal_exts(self): + """Test that two non-existent files with .py extensions result in an exception rather than False return.""" + with self.assertRaises(FileNotFoundError): + files_considered_equal("/path/to/file1.py", "/path/to/somewhere/else/file2.py") + + def test_unequal_exts(self): + """Test that two non-existent files with different extensions result in a False return.""" + self.assertFalse(files_considered_equal("/path/to/file1.text", "/path/to/file1.py")) + + @parameterized.expand(SKIP_EXTS) + def test_skip_docs(self, ext): + self.assertTrue(files_considered_equal("file1" + ext, "file2" + ext)) + + @parameterized.expand(TEXT_EQUAL_PAIRS) + def test_equal_text(self, t1, t2): + with TemporaryDirectory() as tmpdir: + p1 = Path(tmpdir, "t1.py") + p2 = Path(tmpdir, "t2.py") + p1.write_text(t1) + p2.write_text(t2) + + self.assertTrue(files_considered_equal(p1, p2)) + + @parameterized.expand(TEXT_UNEQUAL_PAIRS) + def test_unequal_text(self, t1, t2): + with TemporaryDirectory() as tmpdir: + p1 = Path(tmpdir, "t1.py") + p2 = Path(tmpdir, "t2.py") + p1.write_text(t1) + p2.write_text(t2) + + self.assertFalse(files_considered_equal(p1, p2)) + + +if __name__ == "__main__": + unittest.main()