Skip to content

Commit 6e8fa57

Browse files
authored
[CI] add windows (#995)
* add windows * fix windows regressions * optimize utils * fix reports * fix figure error * refactor testing.yml
1 parent 013ba3d commit 6e8fa57

7 files changed

Lines changed: 151 additions & 37 deletions

File tree

.github/workflows/testing.yml

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ concurrency:
1212
group: testing-${{ github.ref }}
1313
cancel-in-progress: true
1414

15+
env:
16+
PYTEST_COMMON_ARGS: --cov-append --cov-report=xml --cov=nimare nimare
17+
PYTEST_UNIT_MARKERS: not performance_estimators and not performance_correctors and not performance_smoke and not cbmr_importerror
18+
PYTEST_PERFORMANCE_ESTIMATORS_MARKERS: performance_estimators
19+
PYTEST_PERFORMANCE_CORRECTORS_MARKERS: performance_correctors
20+
PYTEST_PERFORMANCE_SMOKE_MARKERS: performance_smoke
21+
PYTEST_CBMR_IMPORTERROR_MARKERS: cbmr_importerror
22+
1523
jobs:
1624
# Determine if tests should be run based on commit message.
1725
check_skip:
@@ -37,7 +45,7 @@ jobs:
3745
strategy:
3846
fail-fast: false
3947
matrix:
40-
os: ["ubuntu-latest", "macos-latest"]
48+
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
4149
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
4250

4351
defaults:
@@ -51,10 +59,10 @@ jobs:
5159
python-version: ${{ matrix.python-version }}
5260
- name: "Install NiMARE"
5361
shell: bash {0}
54-
run: pip install -e .[tests,cbmr]
62+
run: python -m pip install -e .[tests,cbmr]
5563
- name: "Run tests"
5664
shell: bash {0}
57-
run: make unittest
65+
run: python -m pytest -m "${PYTEST_UNIT_MARKERS}" ${PYTEST_COMMON_ARGS}
5866
- name: Upload artifacts
5967
uses: actions/upload-artifact@v4
6068
with:
@@ -83,10 +91,10 @@ jobs:
8391
python-version: "3.10"
8492
- name: "Install NiMARE"
8593
shell: bash {0}
86-
run: pip install -e .[minimum,tests,cbmr]
94+
run: python -m pip install -e .[minimum,tests,cbmr]
8795
- name: "Run tests"
8896
shell: bash {0}
89-
run: make unittest
97+
run: python -m pytest -m "${PYTEST_UNIT_MARKERS}" ${PYTEST_COMMON_ARGS}
9098
- name: Upload artifacts
9199
uses: actions/upload-artifact@v4
92100
with:
@@ -115,10 +123,10 @@ jobs:
115123
python-version: ${{ matrix.python-version }}
116124
- name: "Install NiMARE"
117125
shell: bash {0}
118-
run: pip install -e .[tests,cbmr]
126+
run: python -m pip install -e .[tests,cbmr]
119127
- name: "Run tests"
120128
shell: bash {0}
121-
run: make test_performance_estimators
129+
run: python -m pytest -m "${PYTEST_PERFORMANCE_ESTIMATORS_MARKERS}" ${PYTEST_COMMON_ARGS}
122130
- name: Upload artifacts
123131
uses: actions/upload-artifact@v4
124132
with:
@@ -147,10 +155,10 @@ jobs:
147155
python-version: ${{ matrix.python-version }}
148156
- name: "Install NiMARE"
149157
shell: bash {0}
150-
run: pip install -e .[tests,cbmr]
158+
run: python -m pip install -e .[tests,cbmr]
151159
- name: "Run tests"
152160
shell: bash {0}
153-
run: make test_performance_correctors
161+
run: python -m pytest -m "${PYTEST_PERFORMANCE_CORRECTORS_MARKERS}" ${PYTEST_COMMON_ARGS}
154162
- name: Upload artifacts
155163
uses: actions/upload-artifact@v4
156164
with:
@@ -179,10 +187,10 @@ jobs:
179187
python-version: ${{ matrix.python-version }}
180188
- name: "Install NiMARE"
181189
shell: bash {0}
182-
run: pip install -e .[tests,cbmr]
190+
run: python -m pip install -e .[tests,cbmr]
183191
- name: "Run tests"
184192
shell: bash {0}
185-
run: make test_performance_smoke
193+
run: python -m pytest -m "${PYTEST_PERFORMANCE_SMOKE_MARKERS}" ${PYTEST_COMMON_ARGS}
186194
- name: Upload artifacts
187195
uses: actions/upload-artifact@v4
188196
with:
@@ -211,10 +219,10 @@ jobs:
211219
python-version: ${{ matrix.python-version }}
212220
- name: "Install NiMARE"
213221
shell: bash {0}
214-
run: pip install -e .[tests]
222+
run: python -m pip install -e .[tests]
215223
- name: "Run tests"
216224
shell: bash {0}
217-
run: make test_cbmr_importerror
225+
run: python -m pytest -m "${PYTEST_CBMR_IMPORTERROR_MARKERS}" ${PYTEST_COMMON_ARGS}
218226
- name: Upload artifacts
219227
uses: actions/upload-artifact@v4
220228
with:

nimare/reports/base.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,11 @@ def __init__(self, out_dir, config=None):
434434
contents = None
435435
html_anchor = src.relative_to(out_dir)
436436
if ext == ".html":
437-
contents = IFRAME_SNIPPET.format(html_anchor) if iframe else src.read_text()
437+
contents = (
438+
IFRAME_SNIPPET.format(html_anchor)
439+
if iframe
440+
else src.read_text(encoding="utf-8")
441+
)
438442
if dropdown:
439443
contents = (
440444
f"<details><summary>Advanced ({self.title})</summary>{contents}</details>"
@@ -630,7 +634,7 @@ def __init__(
630634
def _load_config(self, config):
631635
from yaml import safe_load as load
632636

633-
settings = load(config.read_text())
637+
settings = load(config.read_text(encoding="utf-8"))
634638
self.packagename = settings.get("package", None)
635639

636640
self.index(settings["sections"])

nimare/reports/figures.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Plot figures for report."""
22

33
import matplotlib as mpl
4+
5+
mpl.use("Agg", force=True)
46
import matplotlib.colors as mcolors
57
import matplotlib.patches as mpatches
68
import matplotlib.pyplot as plt

nimare/tests/test_extract.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def _local_neurosynth_manifest():
5353

5454

5555
@patch("nimare.extract.extract.urlopen", side_effect=mock_urlopen)
56-
def test_fetch_neurosynth(tmp_path_factory):
56+
def test_fetch_neurosynth(mock_url, tmp_path_factory):
5757
"""Smoke test for extract.fetch_neurosynth.
5858
5959
Taken from the Neurosynth Python package.

nimare/tests/test_reports.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""Test nimare.reports."""
22

33
import os.path as op
4+
from pathlib import Path
45

56
import pytest
67

78
from nimare.correct import FWECorrector
89
from nimare.diagnostics import FocusCounter, Jackknife
910
from nimare.meta.cbma import ALESubtraction
1011
from nimare.meta.ibma import FixedEffectsHedges, Stouffers
11-
from nimare.reports.base import run_reports
12+
from nimare.reports.base import Reportlet, run_reports
1213
from nimare.workflows import CBMAWorkflow, IBMAWorkflow, PairwiseCBMAWorkflow
1314

1415

@@ -158,6 +159,26 @@ def test_reports_alesubtraction_montecarlo_uses_pairwise_mass_map(
158159

159160
summary_file = op.join(tmpdir, "figures", "corrector_figure-summary.html")
160161
assert op.isfile(summary_file)
161-
with open(summary_file) as fo:
162+
with open(summary_file, encoding="utf-8") as fo:
162163
summary_text = fo.read()
163164
assert "z_desc-group1MinusGroup2Mass_level-cluster_corr-FWE_method-montecarlo" in summary_text
165+
166+
167+
def test_reportlet_reads_utf8_html(tmp_path):
168+
"""Reportlets should read generated HTML fragments as UTF-8 on all platforms."""
169+
expected_text = "caf\u00e9 \u2014 \u4f60\u597d"
170+
figures_dir = tmp_path / "figures"
171+
figures_dir.mkdir()
172+
html_file = figures_dir / "utf8_fragment.html"
173+
html_file.write_text(f"<div>{expected_text}</div>", encoding="utf-8")
174+
175+
reportlet = Reportlet(
176+
Path(tmp_path),
177+
config={
178+
"name": "utf8_fragment",
179+
"bids": {"value": "utf8_fragment", "suffix": "html"},
180+
},
181+
)
182+
183+
assert len(reportlet.components) == 1
184+
assert expected_text in reportlet.components[0][0]

nimare/tests/test_utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import nibabel as nib
88
import numpy as np
9+
import pandas as pd
910
import pytest
1011

1112
from nimare import utils
@@ -147,6 +148,25 @@ def bad_justin_timberlake(self):
147148
assert first_memmap_filename != my_class.memmap_filenames[0]
148149

149150

151+
def test_validate_images_df_preserves_existing_relative_columns():
152+
"""Absolute image columns should not duplicate an existing relative column."""
153+
image_df = pd.DataFrame(
154+
{
155+
"id": ["study-1"],
156+
"study_id": ["study"],
157+
"contrast_id": ["1"],
158+
"beta": ["C:/Users/runneradmin/nimare/tests/data/orig/study_beta.nii.gz"],
159+
"beta__relative": ["orig/study_beta.nii.gz"],
160+
}
161+
)
162+
163+
validated = utils._validate_images_df(image_df)
164+
165+
assert validated.columns.tolist().count("beta__relative") == 1
166+
assert validated.loc[0, "beta"] == image_df.loc[0, "beta"]
167+
assert validated.loc[0, "beta__relative"] == "orig/study_beta.nii.gz"
168+
169+
150170
def test_tal2mni():
151171
"""TODO: Get converted coords from official site."""
152172
test = np.array([[-44, 31, 27], [20, -32, 14], [28, -76, 28]])

nimare/utils.py

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import logging
77
import multiprocessing as mp
8+
import ntpath
89
import os
910
import os.path as op
1011
import re
@@ -543,11 +544,44 @@ def _validate_images_df(image_df):
543544
image_df : :class:`pandas.DataFrame`
544545
DataFrame with updated paths and columns.
545546
"""
547+
image_df = image_df.copy(deep=False)
548+
546549
valid_suffixes = [".brik", ".head", ".nii", ".img", ".hed"]
547550
id_columns = set(["id", "study_id", "contrast_id"])
551+
552+
if image_df.columns.has_duplicates:
553+
merged_columns = {}
554+
for col in dict.fromkeys(image_df.columns):
555+
values = image_df.loc[:, col]
556+
if isinstance(values, pd.DataFrame):
557+
merged_columns[col] = values.bfill(axis=1).iloc[:, 0]
558+
else:
559+
merged_columns[col] = values
560+
image_df = pd.DataFrame(merged_columns, index=image_df.index)
561+
562+
def _is_absolute_path(value):
563+
if not isinstance(value, str):
564+
return False
565+
if not value:
566+
return False
567+
if value[0] == "/":
568+
return True
569+
if value.startswith("\\\\") or value.startswith("//"):
570+
return True
571+
return len(value) > 2 and value[1] == ":" and value[2] in ("\\", "/")
572+
573+
def _path_module_for(value):
574+
if len(value) > 1 and value[1] == ":":
575+
return ntpath
576+
if value.startswith("\\"):
577+
return ntpath
578+
return op
579+
548580
# Find columns in the DataFrame with images
549581
file_cols = []
550-
for col in set(image_df.columns) - id_columns:
582+
for col in image_df.columns:
583+
if col in id_columns:
584+
continue
551585
vals = [v for v in image_df[col].values if isinstance(v, str)]
552586
fc = any([any([vs in v for vs in valid_suffixes]) for v in vals])
553587
if fc:
@@ -558,7 +592,7 @@ def _validate_images_df(image_df):
558592
abs_cols = []
559593
for col in file_cols:
560594
files = image_df[col].tolist()
561-
abspaths = [f == op.abspath(f) for f in files if isinstance(f, str)]
595+
abspaths = [_is_absolute_path(f) for f in files if isinstance(f, str)]
562596
if all(abspaths):
563597
abs_cols.append(col)
564598
elif not any(abspaths):
@@ -570,28 +604,52 @@ def _validate_images_df(image_df):
570604
)
571605

572606
# Set relative paths from absolute ones
573-
if len(abs_cols):
574-
all_files = list(np.ravel(image_df[abs_cols].values))
575-
all_files = [f for f in all_files if isinstance(f, str)]
607+
for abs_col in abs_cols:
608+
rel_col = abs_col + "__relative"
609+
abs_values = image_df[abs_col].tolist()
610+
611+
if rel_col in image_df.columns:
612+
rel_values = image_df[rel_col].tolist()
613+
missing_relative = any(
614+
isinstance(abs_val, str) and (not isinstance(rel_val, str) or not rel_val)
615+
for abs_val, rel_val in zip(abs_values, rel_values)
616+
)
617+
if not missing_relative:
618+
continue
619+
else:
620+
rel_values = [None] * len(abs_values)
621+
622+
string_files = [f for f in abs_values if isinstance(f, str)]
623+
if not string_files:
624+
continue
576625

577-
if len(all_files) == 1:
578-
# In the odd case where there's only one absolute path
579-
shared_path = op.dirname(all_files[0]) + op.sep
626+
pathmod = _path_module_for(string_files[0])
627+
normalized_files = [pathmod.normpath(f) if isinstance(f, str) else f for f in abs_values]
628+
string_dirs = [pathmod.dirname(f) for f in normalized_files if isinstance(f, str)]
629+
630+
if len(string_dirs) == 1:
631+
shared_path = string_dirs[0].rstrip(pathmod.sep) + pathmod.sep
580632
else:
581-
shared_path = _find_stem(all_files)
633+
shared_path = pathmod.commonprefix(string_dirs)
634+
if not shared_path.endswith(pathmod.sep):
635+
shared_path = pathmod.dirname(shared_path)
636+
shared_path = shared_path.rstrip(pathmod.sep) + pathmod.sep
582637

583-
# Get parent *directory* if shared path includes common prefix.
584-
if not shared_path.endswith(op.sep):
585-
shared_path = op.dirname(shared_path) + op.sep
586638
LGR.info(f"Shared path detected: '{shared_path}'")
587639

588-
image_df_out = image_df.copy() # To avoid SettingWithCopyWarning
589-
for abs_col in abs_cols:
590-
image_df_out[abs_col + "__relative"] = image_df[abs_col].apply(
591-
lambda x: x.split(shared_path)[1] if isinstance(x, str) else x
592-
)
640+
relative_values = []
641+
for norm_value, rel_value in zip(normalized_files, rel_values):
642+
if isinstance(rel_value, str) and rel_value:
643+
relative_values.append(rel_value)
644+
elif isinstance(norm_value, str):
645+
if shared_path and norm_value.startswith(shared_path):
646+
relative_values.append(norm_value[len(shared_path) :])
647+
else:
648+
relative_values.append(pathmod.basename(norm_value))
649+
else:
650+
relative_values.append(rel_value)
593651

594-
image_df = image_df_out
652+
image_df[rel_col] = relative_values
595653

596654
# Normalize missing values to None (avoid NaN floats in path columns).
597655
# Pandas may keep float dtypes; force object to retain None.
@@ -729,7 +787,8 @@ def memmap_context(self, *args, **kwargs):
729787
self.memmap_filenames, filenames = [], []
730788
for i_file in range(n_files):
731789
start_time = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
732-
_, filename = mkstemp(prefix=self.__class__.__name__, suffix=start_time)
790+
fd, filename = mkstemp(prefix=self.__class__.__name__, suffix=start_time)
791+
os.close(fd)
733792
logger.debug(f"Temporary file written to {filename}")
734793
self.memmap_filenames.append(filename)
735794
filenames.append(filename)

0 commit comments

Comments
 (0)