From db3537a843e91d19fd3faea4c964538825856bc3 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Thu, 1 Feb 2024 14:56:59 +0000 Subject: [PATCH 01/14] Add skimage missing dependency and boot broken pytest-lazy-fixture --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 92cad582..0277bab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,11 +46,11 @@ dev = [ "pre-commit", "pyinstrument", "pytest-cov", - "pytest-lazy-fixture", "pytest-mock", "pytest-qt", "pytest-timeout", "pytest", + "scikit-image", "tox", ] napari = [ @@ -125,7 +125,6 @@ commands = python -m pytest -v --color=yes deps = pytest pytest-cov - pytest-lazy-fixture pytest-mock pytest-timeout # Even though napari is a requirement for cellfinder.napari, we have to From f4f4b04dac0e3b48adff13e21235b511e8dfe02c Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Thu, 1 Feb 2024 14:58:18 +0000 Subject: [PATCH 02/14] Remove lazy-fixture dependent fixtures --- tests/core/test_integration/test_detection.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/core/test_integration/test_detection.py b/tests/core/test_integration/test_detection.py index ed289d10..a29ada9f 100644 --- a/tests/core/test_integration/test_detection.py +++ b/tests/core/test_integration/test_detection.py @@ -45,13 +45,14 @@ def background_array(): # FIXME: This isn't a very good example @pytest.mark.slow @pytest.mark.parametrize( - "n_free_cpus", + "free_cpus", [ - pytest.lazy_fixture("no_free_cpus"), - pytest.lazy_fixture("run_on_one_cpu_only"), + pytest.param("no_free_cpus", id="No free CPUs"), + pytest.param("run_on_one_cpu_only", id="One CPU"), ], ) -def test_detection_full(signal_array, background_array, n_free_cpus): +def test_detection_full(signal_array, background_array, free_cpus, request): + n_free_cpus = request.getfixturevalue(free_cpus) cells_test = main( signal_array, background_array, From 67caf8256f3bb1f6d904b69799738acbb8fa975b Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 6 Feb 2024 10:19:02 +0000 Subject: [PATCH 03/14] Clarify problem fixture docstrings --- .gitignore | 1 + tests/core/conftest.py | 14 +++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 7a8b1bc9..607743bb 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,4 @@ benchmarks/env */_version.py .idea/ +.vscode/ \ No newline at end of file diff --git a/tests/core/conftest.py b/tests/core/conftest.py index f05ec88a..9a8a6544 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -9,18 +9,26 @@ from cellfinder.core.tools.prep import DEFAULT_INSTALL_PATH -@pytest.fixture(scope="session") +@pytest.fixture() def no_free_cpus() -> int: """ - Set number of free CPUs so all available CPUs are used by the tests. + Set number of free CPUs, + so all available CPUs are used by the tests. + + Note that this is passed to min_cpus_to_keep_free, + so a value of 0 implies no CPUs will be kept free, + IE all will be used. """ return 0 -@pytest.fixture(scope="session") +@pytest.fixture() def run_on_one_cpu_only() -> int: """ Set number of free CPUs so tests can use exactly one CPU. + + Note that this is passed to min_cpus_to_keep_free, + so a value of #cpus-1 implies all but one CPU will be kept free. """ cpus = os.cpu_count() if cpus is not None: From dd0e519332baa6afe04fa3b3a4d49e16e0016947 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 6 Feb 2024 10:19:33 +0000 Subject: [PATCH 04/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 607743bb..31dec9e3 100644 --- a/.gitignore +++ b/.gitignore @@ -143,4 +143,4 @@ benchmarks/env */_version.py .idea/ -.vscode/ \ No newline at end of file +.vscode/ From 125fa9def89620eb7b172f881399c9afe08714ac Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 6 Feb 2024 10:52:35 +0000 Subject: [PATCH 05/14] Remove fixtures and get pytest to check for cpu availability at runtime --- tests/core/conftest.py | 28 --------- tests/core/test_integration/test_detection.py | 58 +++++++++++++++---- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/tests/core/conftest.py b/tests/core/conftest.py index 9a8a6544..f68f3a26 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -9,34 +9,6 @@ from cellfinder.core.tools.prep import DEFAULT_INSTALL_PATH -@pytest.fixture() -def no_free_cpus() -> int: - """ - Set number of free CPUs, - so all available CPUs are used by the tests. - - Note that this is passed to min_cpus_to_keep_free, - so a value of 0 implies no CPUs will be kept free, - IE all will be used. - """ - return 0 - - -@pytest.fixture() -def run_on_one_cpu_only() -> int: - """ - Set number of free CPUs so tests can use exactly one CPU. - - Note that this is passed to min_cpus_to_keep_free, - so a value of #cpus-1 implies all but one CPU will be kept free. - """ - cpus = os.cpu_count() - if cpus is not None: - return cpus - 1 - else: - raise ValueError("No CPUs available.") - - @pytest.fixture(scope="session") def download_default_model(): """ diff --git a/tests/core/test_integration/test_detection.py b/tests/core/test_integration/test_detection.py index a29ada9f..1a43dca5 100644 --- a/tests/core/test_integration/test_detection.py +++ b/tests/core/test_integration/test_detection.py @@ -45,14 +45,43 @@ def background_array(): # FIXME: This isn't a very good example @pytest.mark.slow @pytest.mark.parametrize( - "free_cpus", + "cpus_to_leave_available", [ - pytest.param("no_free_cpus", id="No free CPUs"), - pytest.param("run_on_one_cpu_only", id="One CPU"), + pytest.param(0, id="Leave no CPUS free"), + pytest.param(-1, id="Only use one CPU"), ], ) -def test_detection_full(signal_array, background_array, free_cpus, request): - n_free_cpus = request.getfixturevalue(free_cpus) +def test_detection_full( + signal_array, background_array, cpus_to_leave_available: int +): + """ + cpus_to_leave_available is interpreted as follows: + + - For values >=0, this is the number of CPUs to leave available + to the system when running this test. + - For values <0, this is HOW MANY CPUS to request be used to + run the test. + + In each case, we check that we will be running on at least one CPU, + and not requesting more CPUs than the system can provide. + """ + # Determine the number of CPUs to leave available + system_cpus = os.cpu_count() + # How many CPUs do we want to leave free? + if cpus_to_leave_available >= 0: + n_free_cpus = cpus_to_leave_available + else: + # Number of CPUs to keep free is <0, interpret as + # number of CPUs _to use_. Thus; + # n_free_cpus = system_cpus - |cpus_to_leave_available| + n_free_cpus = system_cpus - abs(cpus_to_leave_available) + # Check that there are enough CPUs + if not 0 <= n_free_cpus < system_cpus: + RuntimeError( + f"Not enough CPUS available (you want to leave {n_free_cpus} " + f"available, but there are only {system_cpus} on the system)." + ) + cells_test = main( signal_array, background_array, @@ -80,10 +109,13 @@ def test_detection_full(signal_array, background_array, free_cpus, request): def test_detection_small_planes( - signal_array, background_array, no_free_cpus, mocker + signal_array, + background_array, + mocker, + cpus_to_leave_free: int = 0, ): # Check that processing works when number of planes < number of processes - nproc = get_num_processes(no_free_cpus) + nproc = get_num_processes(cpus_to_leave_free) n_planes = 2 # Don't want to bother classifying in this test, so mock classifcation @@ -100,11 +132,13 @@ def test_detection_small_planes( background_array[0:n_planes], voxel_sizes, ball_z_size=5, - n_free_cpus=no_free_cpus, + n_free_cpus=cpus_to_leave_free, ) -def test_callbacks(signal_array, background_array, no_free_cpus): +def test_callbacks( + signal_array, background_array, cpus_to_leave_free: int = 0 +): # 20 is minimum number of planes needed to find > 0 cells signal_array = signal_array[0:20] background_array = background_array[0:20] @@ -129,7 +163,7 @@ def detect_finished_callback(points): detect_callback=detect_callback, classify_callback=classify_callback, detect_finished_callback=detect_finished_callback, - n_free_cpus=no_free_cpus, + n_free_cpus=cpus_to_leave_free, ) np.testing.assert_equal(planes_done, np.arange(len(signal_array))) @@ -147,13 +181,13 @@ def test_floating_point_error(signal_array, background_array): main(signal_array, background_array, voxel_sizes) -def test_synthetic_data(synthetic_bright_spots, no_free_cpus): +def test_synthetic_data(synthetic_bright_spots, cpus_to_leave_free: int = 0): signal_array, background_array = synthetic_bright_spots detected = main( signal_array, background_array, voxel_sizes, - n_free_cpus=no_free_cpus, + n_free_cpus=cpus_to_leave_free, ) assert len(detected) == 8 From bdd0d91d5fbb0a0eeb5daceb7073629cc2d2a568 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 6 Feb 2024 10:52:54 +0000 Subject: [PATCH 06/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/core/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/core/conftest.py b/tests/core/conftest.py index f68f3a26..83778a9f 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -1,4 +1,3 @@ -import os from typing import Tuple import numpy as np From 1545b1b86e739fd0a159f605a56ebbf01f6bd9b9 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 6 Feb 2024 10:55:00 +0000 Subject: [PATCH 07/14] Actually raise the exception for not enough CPUs --- tests/core/test_integration/test_detection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_integration/test_detection.py b/tests/core/test_integration/test_detection.py index 1a43dca5..f7dffdc0 100644 --- a/tests/core/test_integration/test_detection.py +++ b/tests/core/test_integration/test_detection.py @@ -77,7 +77,7 @@ def test_detection_full( n_free_cpus = system_cpus - abs(cpus_to_leave_available) # Check that there are enough CPUs if not 0 <= n_free_cpus < system_cpus: - RuntimeError( + raise RuntimeError( f"Not enough CPUS available (you want to leave {n_free_cpus} " f"available, but there are only {system_cpus} on the system)." ) From 2753abb7a184f13a169de0ac111eb8bef80a2c1e Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 6 Feb 2024 11:03:11 +0000 Subject: [PATCH 08/14] try attempting garbage collector to run at end of test --- tests/core/test_integration/test_detection.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/core/test_integration/test_detection.py b/tests/core/test_integration/test_detection.py index f7dffdc0..a2e9bc9b 100644 --- a/tests/core/test_integration/test_detection.py +++ b/tests/core/test_integration/test_detection.py @@ -1,3 +1,4 @@ +import gc import os from math import isclose @@ -107,6 +108,9 @@ def test_detection_full( num_cells_validation, num_cells_test, abs_tol=DETECTION_TOLERANCE ) + # Force explicit memory cleanup + gc.collect() + def test_detection_small_planes( signal_array, From 4d19fb6fb0aea5e10b81fda68d849a7317adaa50 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 6 Feb 2024 16:07:54 +0000 Subject: [PATCH 09/14] Remove double dependency --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0277bab5..17f6d235 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,6 @@ dev = [ "pytest-qt", "pytest-timeout", "pytest", - "scikit-image", "tox", ] napari = [ From 448170bc9f76b3fbc4f46a87da18a5dd3421e67d Mon Sep 17 00:00:00 2001 From: Alessandro Felder Date: Wed, 7 Feb 2024 09:23:14 +0000 Subject: [PATCH 10/14] improve detection debug logs (#375) * improve detection debug logs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove too wordy debug logs --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- cellfinder/core/detect/detect.py | 8 ++++---- cellfinder/core/detect/filters/volume/volume_filter.py | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cellfinder/core/detect/detect.py b/cellfinder/core/detect/detect.py index cd4e22c5..7c614d4e 100644 --- a/cellfinder/core/detect/detect.py +++ b/cellfinder/core/detect/detect.py @@ -179,11 +179,11 @@ def main( # processes. cells = mp_3d_filter.process(async_results, locks, callback=callback) - print( - "Detection complete - all planes done in : {}".format( - datetime.now() - start_time - ) + time_elapsed = datetime.now() - start_time + logger.debug( + f"All Planes done. Found {len(cells)} cells in {format(time_elapsed)}" ) + print("Detection complete - all planes done in : {}".format(time_elapsed)) return cells diff --git a/cellfinder/core/detect/filters/volume/volume_filter.py b/cellfinder/core/detect/filters/volume/volume_filter.py index a3e4d98f..d64ae71a 100644 --- a/cellfinder/core/detect/filters/volume/volume_filter.py +++ b/cellfinder/core/detect/filters/volume/volume_filter.py @@ -142,6 +142,10 @@ def get_results(self) -> List[Cell]: ) cells = [] + + logger.debug( + f"Processing {len(self.cell_detector.coords_maps.items())} cells" + ) for cell_id, cell_points in self.cell_detector.coords_maps.items(): cell_volume = len(cell_points) @@ -191,6 +195,7 @@ def get_results(self) -> List[Cell]: ) ) + logger.debug("Finished splitting cell clusters.") return cells From 4b840b3d0f03057cdbb928b79caeb6972e425684 Mon Sep 17 00:00:00 2001 From: Will Graham <32364977+willGraham01@users.noreply.github.com> Date: Wed, 7 Feb 2024 11:56:20 +0000 Subject: [PATCH 11/14] Searching for min. segfault example (#376) Reverting apparently un-neccessary changes due to conflation with a bad cache fetch. * Revert "try attempting garbage collector to run at end of test" This reverts commit 2753abb7a184f13a169de0ac111eb8bef80a2c1e. * Revert "Actually raise the exception for not enough CPUs" This reverts commit 1545b1b86e739fd0a159f605a56ebbf01f6bd9b9. * Revert "[pre-commit.ci] auto fixes from pre-commit.com hooks" This reverts commit bdd0d91d5fbb0a0eeb5daceb7073629cc2d2a568. * Revert "Remove fixtures and get pytest to check for cpu availability at runtime" This reverts commit 125fa9def89620eb7b172f881399c9afe08714ac. * Revert "[pre-commit.ci] auto fixes from pre-commit.com hooks" This reverts commit dd0e519332baa6afe04fa3b3a4d49e16e0016947. * Revert "Clarify problem fixture docstrings" This reverts commit 67caf8256f3bb1f6d904b69799738acbb8fa975b. * Add a timeout to CI for runs --- .github/workflows/test_and_deploy.yml | 1 + .gitignore | 1 - tests/core/conftest.py | 21 +++++++ tests/core/test_integration/test_detection.py | 62 ++++--------------- 4 files changed, 34 insertions(+), 51 deletions(-) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 79e765b4..8c53d434 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -35,6 +35,7 @@ jobs: test: needs: [linting, manifest] name: Run package tests + timeout-minutes: 20 runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/.gitignore b/.gitignore index 31dec9e3..7a8b1bc9 100644 --- a/.gitignore +++ b/.gitignore @@ -143,4 +143,3 @@ benchmarks/env */_version.py .idea/ -.vscode/ diff --git a/tests/core/conftest.py b/tests/core/conftest.py index 83778a9f..f05ec88a 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -1,3 +1,4 @@ +import os from typing import Tuple import numpy as np @@ -8,6 +9,26 @@ from cellfinder.core.tools.prep import DEFAULT_INSTALL_PATH +@pytest.fixture(scope="session") +def no_free_cpus() -> int: + """ + Set number of free CPUs so all available CPUs are used by the tests. + """ + return 0 + + +@pytest.fixture(scope="session") +def run_on_one_cpu_only() -> int: + """ + Set number of free CPUs so tests can use exactly one CPU. + """ + cpus = os.cpu_count() + if cpus is not None: + return cpus - 1 + else: + raise ValueError("No CPUs available.") + + @pytest.fixture(scope="session") def download_default_model(): """ diff --git a/tests/core/test_integration/test_detection.py b/tests/core/test_integration/test_detection.py index a2e9bc9b..a29ada9f 100644 --- a/tests/core/test_integration/test_detection.py +++ b/tests/core/test_integration/test_detection.py @@ -1,4 +1,3 @@ -import gc import os from math import isclose @@ -46,43 +45,14 @@ def background_array(): # FIXME: This isn't a very good example @pytest.mark.slow @pytest.mark.parametrize( - "cpus_to_leave_available", + "free_cpus", [ - pytest.param(0, id="Leave no CPUS free"), - pytest.param(-1, id="Only use one CPU"), + pytest.param("no_free_cpus", id="No free CPUs"), + pytest.param("run_on_one_cpu_only", id="One CPU"), ], ) -def test_detection_full( - signal_array, background_array, cpus_to_leave_available: int -): - """ - cpus_to_leave_available is interpreted as follows: - - - For values >=0, this is the number of CPUs to leave available - to the system when running this test. - - For values <0, this is HOW MANY CPUS to request be used to - run the test. - - In each case, we check that we will be running on at least one CPU, - and not requesting more CPUs than the system can provide. - """ - # Determine the number of CPUs to leave available - system_cpus = os.cpu_count() - # How many CPUs do we want to leave free? - if cpus_to_leave_available >= 0: - n_free_cpus = cpus_to_leave_available - else: - # Number of CPUs to keep free is <0, interpret as - # number of CPUs _to use_. Thus; - # n_free_cpus = system_cpus - |cpus_to_leave_available| - n_free_cpus = system_cpus - abs(cpus_to_leave_available) - # Check that there are enough CPUs - if not 0 <= n_free_cpus < system_cpus: - raise RuntimeError( - f"Not enough CPUS available (you want to leave {n_free_cpus} " - f"available, but there are only {system_cpus} on the system)." - ) - +def test_detection_full(signal_array, background_array, free_cpus, request): + n_free_cpus = request.getfixturevalue(free_cpus) cells_test = main( signal_array, background_array, @@ -108,18 +78,12 @@ def test_detection_full( num_cells_validation, num_cells_test, abs_tol=DETECTION_TOLERANCE ) - # Force explicit memory cleanup - gc.collect() - def test_detection_small_planes( - signal_array, - background_array, - mocker, - cpus_to_leave_free: int = 0, + signal_array, background_array, no_free_cpus, mocker ): # Check that processing works when number of planes < number of processes - nproc = get_num_processes(cpus_to_leave_free) + nproc = get_num_processes(no_free_cpus) n_planes = 2 # Don't want to bother classifying in this test, so mock classifcation @@ -136,13 +100,11 @@ def test_detection_small_planes( background_array[0:n_planes], voxel_sizes, ball_z_size=5, - n_free_cpus=cpus_to_leave_free, + n_free_cpus=no_free_cpus, ) -def test_callbacks( - signal_array, background_array, cpus_to_leave_free: int = 0 -): +def test_callbacks(signal_array, background_array, no_free_cpus): # 20 is minimum number of planes needed to find > 0 cells signal_array = signal_array[0:20] background_array = background_array[0:20] @@ -167,7 +129,7 @@ def detect_finished_callback(points): detect_callback=detect_callback, classify_callback=classify_callback, detect_finished_callback=detect_finished_callback, - n_free_cpus=cpus_to_leave_free, + n_free_cpus=no_free_cpus, ) np.testing.assert_equal(planes_done, np.arange(len(signal_array))) @@ -185,13 +147,13 @@ def test_floating_point_error(signal_array, background_array): main(signal_array, background_array, voxel_sizes) -def test_synthetic_data(synthetic_bright_spots, cpus_to_leave_free: int = 0): +def test_synthetic_data(synthetic_bright_spots, no_free_cpus): signal_array, background_array = synthetic_bright_spots detected = main( signal_array, background_array, voxel_sizes, - n_free_cpus=cpus_to_leave_free, + n_free_cpus=no_free_cpus, ) assert len(detected) == 8 From e6b233cf6b56b185861d7b4fba72e7d899588b1a Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Wed, 7 Feb 2024 11:57:36 +0000 Subject: [PATCH 12/14] ignore vscode configs in git --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7a8b1bc9..607743bb 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,4 @@ benchmarks/env */_version.py .idea/ +.vscode/ \ No newline at end of file From ae0e847a6c10cb31fb37675b2fa0ebd02d4fb608 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 7 Feb 2024 12:00:09 +0000 Subject: [PATCH 13/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 607743bb..31dec9e3 100644 --- a/.gitignore +++ b/.gitignore @@ -143,4 +143,4 @@ benchmarks/env */_version.py .idea/ -.vscode/ \ No newline at end of file +.vscode/ From 4c20e9bb62b76ca5a652af5c3638410542498306 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Wed, 7 Feb 2024 13:04:42 +0000 Subject: [PATCH 14/14] Bump timeout to 60 mins --- .github/workflows/test_and_deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 8c53d434..4f941175 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -35,7 +35,7 @@ jobs: test: needs: [linting, manifest] name: Run package tests - timeout-minutes: 20 + timeout-minutes: 60 runs-on: ${{ matrix.os }} strategy: matrix: