diff --git a/.circleci/config.yml b/.circleci/config.yml index 576ef34..d3285f2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -99,8 +99,7 @@ jobs: name: Check style command: | isort --check . - black --check nigsp - flake8 ./nigsp + ruff check nigsp merge_coverage: working_directory: /tmp/src/nigsp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e0d597d..ae229a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,38 +1,46 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - - id: check-case-conflict - - id: check-merge-conflict -- repo: https://github.com/psf/black - rev: 24.4.0 - hooks: - - id: black -- repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort -- repo: https://github.com/pycqa/flake8 - rev: 7.0.0 - hooks: - - id: flake8 -- repo: https://github.com/pycqa/pydocstyle - rev: 6.3.0 - hooks: - - id: pydocstyle -- repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.10.0 - hooks: - - id: rst-backticks - - id: rst-directive-colons - - id: rst-inline-touching-normal -- repo: https://github.com/codespell-project/codespell - rev: v2.2.6 - hooks: - - id: codespell + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + + - repo: https://github.com/pycqa/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal + + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.7 + hooks: + - id: ruff + name: ruff linter + args: [--fix] + files: nigsp + - id: ruff-format + name: ruff formatter + files: nigsp diff --git a/codecov.yml b/codecov.yml index 3a5d947..8b7d7dc 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,43 +1,25 @@ +comment: false +github_checks: # too noisy, even though "a" interactively disables them + annotations: false + codecov: - branch: master - strict_yaml_branch: master - require_ci_to_pass: true - bot: "codecov-io" - max_report_age: 48 - disable_default_path_fixes: false + notify: + require_ci_to_pass: false coverage: - precision: 2 - round: down - range: "60...90" - -parsers: - gcov: - branch_detection: - conditional: yes - loop: yes - method: no - macro: no - -ignore: - - "docs" - - "tests" - - "_version.py" - - "__init__.py" - - "**/__init__.py" - - "due.py" - - ".*rc" - - "versioneer.py" - - "setup.py" - - "nigsp/tests" - - "nigsp/_version.py" - - "nigsp/__init__.py" - - "nigsp/**/__init__.py" - - "nigsp/due.py" - - "nigsp/.*rc" - - "nigsp/versioneer.py" - - "nigsp/setup.py" -comment: - layout: "reach,diff,flags,tree" - behavior: default - require_changes: false + status: + patch: + default: + informational: true + target: 95% + if_no_uploads: error + if_not_found: success + if_ci_failed: error + project: + default: false + library: + informational: true + target: 90% + if_no_uploads: error + if_not_found: success + if_ci_failed: error diff --git a/nigsp/_version.py b/nigsp/_version.py index 3291e61..950480b 100644 --- a/nigsp/_version.py +++ b/nigsp/_version.py @@ -84,7 +84,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= stderr=(subprocess.PIPE if hide_stderr else None), ) break - except EnvironmentError: + except EnvironmentError: # noqa e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print(f"unable to find command, tried {commands}") return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -116,7 +116,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): """ rootdirs = [] - for i in range(3): + for _ in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): return { @@ -132,8 +132,8 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): if verbose: print( - "Tried directories %s but none started with prefix %s" - % (str(rootdirs), parentdir_prefix) + f"Tried directories {str(rootdirs)} but none started with prefix " + f"{parentdir_prefix}" ) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -147,7 +147,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs, "r") # noqa: UP015 for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -162,7 +162,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except EnvironmentError: # noqa pass return keywords @@ -302,9 +302,8 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( - full_tag, - tag_prefix, + pieces["error"] = ( + f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" ) return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] @@ -524,7 +523,7 @@ def get_versions(): # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. - for i in cfg.versionfile_source.split("/"): + for _ in cfg.versionfile_source.split("/"): root = os.path.dirname(root) except NameError: return { diff --git a/nigsp/blocks.py b/nigsp/blocks.py index 06fecba..45a01e2 100644 --- a/nigsp/blocks.py +++ b/nigsp/blocks.py @@ -68,7 +68,7 @@ def export_metric(scgraph, outext, outprefix): """ if outext in io.EXT_NIFTI: try: - import nibabel as _ + import nibabel as _ # noqa: F401 except ImportError: LGR.warning( "The necessary library for nifti export (nibabel) " @@ -118,7 +118,6 @@ def plot_metric(scgraph, outprefix, atlas=None, thr=None): """ # Check that atlas format is supported. try: - atlas.header atlas_plot = atlas except AttributeError: try: diff --git a/nigsp/cli/run.py b/nigsp/cli/run.py index e427e90..e7f54fe 100644 --- a/nigsp/cli/run.py +++ b/nigsp/cli/run.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Parser for crispy-octo-broccoli.""" import argparse diff --git a/nigsp/conftest.py b/nigsp/conftest.py index c3c244a..4960549 100644 --- a/nigsp/conftest.py +++ b/nigsp/conftest.py @@ -59,7 +59,7 @@ def fetch_file(osf_id, path, filename): ) ) ssl._create_default_https_context = ssl._create_unverified_context - url = "https://osf.io/{}/download".format(osf_id) + url = f"https://osf.io/{osf_id}/download" full_path = os.path.join(path, filename) if not os.path.isfile(full_path): urlretrieve(url, full_path) diff --git a/nigsp/due.py b/nigsp/due.py index 2b9d844..55a093c 100644 --- a/nigsp/due.py +++ b/nigsp/due.py @@ -27,7 +27,7 @@ __version__ = "0.0.9" -class InactiveDueCreditCollector(object): +class InactiveDueCreditCollector: """Just a stub at the Collector which would not do anything""" def _donothing(self, *args, **kwargs): diff --git a/nigsp/objects.py b/nigsp/objects.py index 635f062..016ab9b 100644 --- a/nigsp/objects.py +++ b/nigsp/objects.py @@ -130,7 +130,7 @@ def compute_graph_energy(self, mean=False): # pragma: no cover ) return self - def split_graph(self, index=None, keys=["low", "high"]): + def split_graph(self, index=None, keys=["low", "high"]): # noqa: B006 """Implement timeseries.median_cutoff_frequency_idx as class method.""" if index is None: index = self.index @@ -138,7 +138,7 @@ def split_graph(self, index=None, keys=["low", "high"]): if index == "median": # pragma: no cover index = operations.median_cutoff_frequency_idx(self.energy) - elif type(index) is not int: + elif not isinstance(index, int): raise ValueError( f"Unknown option {index} for frequency split index. " "Declared index must be either an integer or 'median'" diff --git a/nigsp/operations/laplacian.py b/nigsp/operations/laplacian.py index b319bc3..0c646b7 100644 --- a/nigsp/operations/laplacian.py +++ b/nigsp/operations/laplacian.py @@ -45,7 +45,8 @@ def compute_laplacian(mtx, negval="absolute", selfloops=False): numpy.ndarray The laplacian of mtx numpy.ndarray - The degree matrix of mtx as a (mtx.ndim-1)D array, updated with selfloops in case. + The degree matrix of mtx as a (mtx.ndim-1)D array, updated with selfloops in + case. Raises ------ @@ -104,8 +105,7 @@ def compute_laplacian(mtx, negval="absolute", selfloops=False): def normalisation(lapl, degree, norm="symmetric", fix_zeros=True): - """ - Normalise a Laplacian (L) matrix using either symmetric or random walk normalisation. + """Normalise a Laplacian (L) matrix using either symmetric or random walk normalisation. Parameters ---------- @@ -125,8 +125,8 @@ def normalisation(lapl, degree, norm="symmetric", fix_zeros=True): It normalises the outflow, i.e. it is column-optimised (each column = 0). Normally used in e.g. physical distribution networks. fix_zeros : bool, optional - Whether to change 0 elements in the degree matrix to 1 to avoid multiplying by 0. - Default is to do so. + Whether to change 0 elements in the degree matrix to 1 to avoid multiplying by + 0. Default is to do so. Returns ------- @@ -145,7 +145,7 @@ def normalisation(lapl, degree, norm="symmetric", fix_zeros=True): Notes ----- https://en.wikipedia.org/wiki/Laplacian_matrix - """ + """ # noqa: E501 deg = deepcopy(degree) if lapl.ndim - deg.ndim > 1: raise NotImplementedError( diff --git a/nigsp/operations/metrics.py b/nigsp/operations/metrics.py index 6f447f8..7801b39 100644 --- a/nigsp/operations/metrics.py +++ b/nigsp/operations/metrics.py @@ -206,7 +206,8 @@ def _fc(timeseries, mean=False): timeseries : numpy.ndarray A 2- or 3-D matrix containing timeseries along axis 1. mean : bool, optional - If timeseries is 3D and this is True, return the average FC along the last axis. + If timeseries is 3D and this is True, return the average FC along the last + axis. Returns ------- @@ -236,7 +237,7 @@ def _fc(timeseries, mean=False): fcorr = fcorr.mean(axis=2).squeeze() return fcorr - if type(timeseries) is dict: + if isinstance(timeseries, dict): fc = dict() for k in timeseries.keys(): fc[k] = _fc(timeseries[k], mean) diff --git a/nigsp/operations/nifti.py b/nigsp/operations/nifti.py index a83a156..eb4c841 100644 --- a/nigsp/operations/nifti.py +++ b/nigsp/operations/nifti.py @@ -175,9 +175,11 @@ def apply_atlas(data, atlas, mask=None): data : numpy.ndarray A 3- or 4- D matrix (normally nifti data) of timeseries. atlas : numpy.ndarray - A 2- or 3- D matrix representing the atlas, each parcel represented by a different int. + A 2- or 3- D matrix representing the atlas, each parcel represented by a + different int. mask : None or numpy.ndarray, optional - A 2- or 3- D matrix representing a mask, all voxels == 0 are excluded from the computation. + A 2- or 3- D matrix representing a mask, all voxels == 0 are excluded from the + computation. Returns ------- @@ -230,8 +232,8 @@ def apply_atlas(data, atlas, mask=None): parcels = np.empty([len(labels), data.shape[-1]], dtype="float32") # Compute averages - for n, l in enumerate(labels): - parcels[n, :] = data[atlas == l].mean(axis=0) + for n, label in enumerate(labels): + parcels[n, :] = data[atlas == label].mean(axis=0) return parcels @@ -302,8 +304,8 @@ def unfold_atlas(data, atlas, mask=None): if data.shape[ax] > 1: out = out[..., np.newaxis].repeat(data.shape[ax], axis=-1) - for n, l in enumerate(labels): - out[atlas == l] = data[n, ...] + for n, label in enumerate(labels): + out[atlas == label] = data[n, ...] return out diff --git a/nigsp/operations/timeseries.py b/nigsp/operations/timeseries.py index 85b6956..679008b 100644 --- a/nigsp/operations/timeseries.py +++ b/nigsp/operations/timeseries.py @@ -297,7 +297,8 @@ def _trapezoid_compat(*args, **kwargs): def median_cutoff_frequency_idx(energy): """ - Find the frequency that splits the energy of a timeseries in two roughly equal parts. + Find the frequency that splits the energy of a timeseries in two roughly equal + parts. Parameters ---------- @@ -347,7 +348,7 @@ def median_cutoff_frequency_idx(energy): return freq_idx -def graph_filter(timeseries, eigenvec, freq_idx, keys=["low", "high"]): +def graph_filter(timeseries, eigenvec, freq_idx, keys=["low", "high"]): # noqa: B006 """ Filter a graph decomposition into two parts based on freq_idx. diff --git a/nigsp/tests/test_integration.py b/nigsp/tests/test_integration.py index 4103f93..abca485 100644 --- a/nigsp/tests/test_integration.py +++ b/nigsp/tests/test_integration.py @@ -67,7 +67,8 @@ def test_integration(timeseries, sc_mtx, atlas, mean_fc, sdi, testdir): mean_fc_pyt = np.genfromtxt(join(testdir, "testfile_fc.tsv")) # Check that each cell in the result is comparable to matlab's. - # There's a bunch of rounding due to np.round and numerical difference between matlab and python + # There's a bunch of rounding due to np.round and numerical difference between + # matlab and python assert abs(mean_fc_pyt.round(6) - mean_fc_mat.round(6)).max().round(6) <= 0.000001 assert abs(sdi_pyt.round(5) - sdi_mat.round(5)).max().round(5) <= 0.00001 assert (sdi_pyt != sdi_mkd).any() diff --git a/nigsp/tests/test_io.py b/nigsp/tests/test_io.py index 072b60c..b45b21f 100644 --- a/nigsp/tests/test_io.py +++ b/nigsp/tests/test_io.py @@ -48,7 +48,7 @@ def test_check_ext(): assert has_ext is False assert fname_out == fname - assert ext_out == None + assert ext_out is None @mark.parametrize("data", [(rand(3, 4)), (rand(3, 4, 1)), (rand(3, 1, 4))]) diff --git a/nigsp/tests/test_laplacian.py b/nigsp/tests/test_laplacian.py index 06145dd..5b6369e 100644 --- a/nigsp/tests/test_laplacian.py +++ b/nigsp/tests/test_laplacian.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Tests for operations.laplacian.""" + from copy import deepcopy as dc import numpy as np diff --git a/nigsp/tests/test_nifti.py b/nigsp/tests/test_nifti.py index 0800063..84720d7 100644 --- a/nigsp/tests/test_nifti.py +++ b/nigsp/tests/test_nifti.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Tests for operations.nifti.""" + from numpy import asarray, prod, unique, zeros from numpy.random import rand from pytest import raises @@ -108,11 +109,11 @@ def test_unfold_atlas(): dm = zeros((6, 10), dtype="float32") label = unique(a) label = label[label > 0] - for n, l in enumerate(label): - da[a == l] = c[n, :] + for n, label_ in enumerate(label): + da[a == label_] = c[n, :] label = label[label > 1] - for n, l in enumerate(label): - dm[a == l] = cm[n, :] + for n, label_ in enumerate(label): + dm[a == label_] = cm[n, :] r = nifti.unfold_atlas(c, a) rm = nifti.unfold_atlas(cm, a, mask=m) diff --git a/nigsp/tests/test_surrogates.py b/nigsp/tests/test_surrogates.py index 3d275ca..c9febfc 100644 --- a/nigsp/tests/test_surrogates.py +++ b/nigsp/tests/test_surrogates.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Tests for operations.surrogates.""" + import numpy as np from numpy.random import default_rng, rand from pytest import raises diff --git a/nigsp/utils.py b/nigsp/utils.py index bd94013..e2d4e75 100644 --- a/nigsp/utils.py +++ b/nigsp/utils.py @@ -85,7 +85,7 @@ def change_var_type(var, dtype, varname="an input variable", stop=True, silent=F elif dtype is ndarray: tmpvar = asarray(var) elif dtype is list: - if type(var) is list: + if isinstance(var, list): tmpvar = var else: tmpvar = [var] diff --git a/nigsp/workflow.py b/nigsp/workflow.py index 2c79284..f2ab245 100644 --- a/nigsp/workflow.py +++ b/nigsp/workflow.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ `nigsp` main workflow and related functions. @@ -75,7 +74,7 @@ def nigsp( atlasname=None, outname=None, outdir=None, - comp_metric=[], + comp_metric=[], # noqa: B006 index="median", surr_type=None, n_surr=1000, @@ -91,17 +90,20 @@ def nigsp( ---------- fname : str or os.PathLike Path to the timeseries data file. It can be a text, nifti, or matlab file - (and variants). To see the full list of support, check the general documentation. + (and variants). To see the full list of support, check the general + documentation. scname : str or os.PathLike - Path to the structural connectivity data file. It can be a text, nifti, or matlab file - (and variants). To see the full list of support, check the general documentation. + Path to the structural connectivity data file. It can be a text, nifti, or + matlab file (and variants). To see the full list of support, check the general + documentation. atlasname : str, os.PathLike, or None, optional Path to the atlas data file. It can be a text, nifti, or matlab file - (and variants). To see the full list of support, check the general documentation. + (and variants). To see the full list of support, check the general + documentation. outname : str, os.PathLike, or None, optional - Path to the output file - or just its full name. It can be a text, nifti, or matlab file - (and variants). If an extension is *not* declared, or if it is not currently - supported, the program will automatically export a csv file. + Path to the output file - or just its full name. It can be a text, nifti, or + matlab file (and variants). If an extension is *not* declared, or if it is not + currently supported, the program will automatically export a csv file. To see the full list of support, check the general documentation. It is *not* necessary to declare both this and `outdir` - the full path can be specified here. @@ -260,7 +262,7 @@ def nigsp( atlas_is[k], _ = io.check_ext(io.EXT_DICT[k], atlasname) # Check that other inputs are supported - if index != "median" and type(index) is not int: + if index != "median" and not isinstance(index, int): raise ValueError(f"Index {index} of type {type(index)} is not valid.") if method not in surr.STAT_METHOD and method is not None: raise NotImplementedError( @@ -403,7 +405,7 @@ def nigsp( # If possible, create plots! try: import matplotlib as _ - import nilearn as _ + import nilearn as _ # noqa: F811 # Plot original SC and Laplacian LGR.info("Plot laplacian matrix.") @@ -455,7 +457,7 @@ def nigsp( try: import matplotlib as _ - import nilearn as _ + import nilearn as _ # noqa: F811, F401 if atlasname is not None: LGR.info(f"Plot {metric_name} markerplot.") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8439df6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[tool.coverage.report] +exclude_lines = [ + 'if __name__ == .__main__.:', + 'if TYPE_CHECKING:', + 'pragma: no cover', +] +precision = 2 + +[tool.coverage.run] +branch = true +cover_pylib = false +omit = [ + '**/__init__.py', + '**/conftest.py', + '**/nigsp/_version.py', + '**/nigsp/cli/*', + '**/nigsp/due.py', + '**/tests/**', +] + +[tool.isort] +extend_skip_glob = [ + 'docs/*', + 'setup.py', + 'tutorials/*', +] +line_length = 88 +multi_line_output = 3 +profile = 'black' +py_version = 37 + +[tool.ruff] +extend-exclude = ['docs', 'versioneer.py', 'setup.py'] +line-length = 88 +target-version = 'py37' + +[tool.ruff.format] +docstring-code-format = true +line-ending = 'lf' + +[tool.ruff.lint] +ignore = [] +select = ['A', 'B', 'E', 'F', 'UP', 'W'] + +[tool.ruff.lint.per-file-ignores] +'*' = [ + 'B904', # 'Within an except clause, raise exceptions with raise ... from ...' + 'UP007', # 'Use `X | Y` for type annotations', requires python 3.10 +] +'*.pyi' = ['E501'] +'__init__.py' = ['F401'] diff --git a/setup.cfg b/setup.cfg index 1550d22..0bcb058 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,11 +9,12 @@ classifiers = Development Status :: 4 - Beta Intended Audience :: Science/Research License :: OSI Approved :: Apache Software License - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 license = Apache-2.0 description = A python library (and toolbox!) to run Graph Signal Processing on multimodal MRI data. long_description = file:README.md @@ -23,7 +24,7 @@ provides = nigsp [options] -python_requires = >=3.6 +python_requires = >=3.7 install_requires = numpy>=1.17 duecredit @@ -61,15 +62,14 @@ doc = sphinx-gallery sphinx-issues style = - flake8>=4.0 - black<23.0.0 - isort<6.0.0 + isort pydocstyle - codespell + codespell>=2.2.4 + ruff>=0.4.1 test = %(all)s %(style)s - pytest >=8.0 + pytest>=8.0 pytest-cov coverage devtools = @@ -83,35 +83,6 @@ dev = console_scripts = nigsp=nigsp.workflow:_main -[flake8] -doctest = True -exclude = - _version.py - ./nigsp/cli/__init__.py - ./nigsp/tests/* - versioneer.py -ignore = E126, E402, W503, F401, F811 -max-line-length = 88 -extend-ignore = E203, E501 -extend-select = B950 -per-file-ignores = - workflow.py:D401 - -[isort] -profile = black -skip_gitignore = true -extend_skip = - .autorc - .coverage* - .readthedocs.yml - .zenodo.json - codecov.yml - setup.py - versioneer.py - nigsp/_version.py -skip_glob = - docs/* - [pydocstyle] convention = numpy match = @@ -130,19 +101,6 @@ doctest_optionflags = NORMALIZE_WHITESPACE xfail_strict = true addopts = -rx -[coverage:run] -branch = True -omit = - nigsp/tests/* - docs/* - setup.py - versioneer.py - doi.py - conftest.py - __init__.py - */__init__.py - */*/__init__.py - [versioneer] VCS = git style = pep440