diff --git a/.circleci/config.yml b/.circleci/config.yml index aa871e1b..060614f9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,12 +1,12 @@ version: 2.1 orbs: - docker: circleci/docker@2.2.0 - codecov: codecov/codecov@3.2.4 + docker: circleci/docker@2.4.0 + codecov: codecov/codecov@3.3.0 jobs: test_pytest: - docker: # executor type - - image: nipreps/miniconda:py39_2209.01 + docker: + - image: cimg/python:3.12 auth: username: $DOCKER_USER password: $DOCKER_PAT @@ -16,14 +16,13 @@ jobs: - TEMPLATEFLOW_HOME: /tmp/templateflow steps: - checkout + - run: + name: Install package + command: pip install .[test] - restore_cache: keys: - - tf-v0-{{ .Branch }}-{{ .Revision }} - - tf-v0--{{ .Revision }} - - tf-v0-{{ .Branch }}- - - tf-v0-master- - - tf-v0- + - tf-v0 paths: - /tmp/templateflow @@ -34,7 +33,7 @@ jobs: python -c "from templateflow.api import get; get('MNI152NLin6Asym', resolution=2, desc='LR', suffix='T1w')" - save_cache: - key: tf-v0-{{ .Branch }}-{{ .Revision }} + key: tf-v0 paths: - /tmp/templateflow @@ -59,37 +58,6 @@ jobs: flags: unittests - build_docs: - docker: # executor type - - image: nipreps/miniconda:py39_2209.01 - auth: - username: $DOCKER_USER - password: $DOCKER_PAT - - environment: - - FSLOUTPUTTYPE: NIFTI - - SUBJECTS_DIR: /tmp/subjects - steps: - - checkout - - run: - name: Create subjects folder - command: mkdir -p $SUBJECTS_DIR - - run: - name: Install deps - command: | - python -m pip install --no-cache-dir -U "pip>=20.3" - - run: - name: Install Nireports - command: | - python -m pip install .[docs] - - run: - name: Build only this commit - command: | - BRANCH=$( echo $CIRCLE_BRANCH | sed 's+/+_+g' ) - make -C docs SPHINXOPTS="-W" BUILDDIR="/tmp/docs" OUTDIR=${CIRCLE_TAG:-$BRANCH} html - - store_artifacts: - path: /tmp/docs - workflows: version: 2 build_test_deploy: @@ -103,11 +71,3 @@ workflows: branches: ignore: - /docs?\/.*/ - - - build_docs: - filters: - branches: - ignore: - - /tests?\/.*/ - tags: - only: /.*/ diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index 0087b21c..b41c18a3 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -52,7 +52,7 @@ jobs: matrix: #os: ['ubuntu-latest', 'windows-latest', 'macos-latest'] os: ['ubuntu-latest'] - python-version: [3.8, 3.9, '3.10'] + python-version: [3.8, 3.9, '3.10', '3.11', '3.12'] architecture: ['x64', 'x86'] package: ['.', 'dist/*.whl', 'dist/*.tar.gz'] exclude: @@ -80,24 +80,19 @@ jobs: TEMPLATEFLOW_HOME: /tmp/templateflow steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Fetch packages uses: actions/download-artifact@v3 with: name: dist path: dist/ - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.architecture }} - name: Display Python version run: python -c "import sys; print(sys.version)" - - name: Generate requirements file and install them - run: | - python -m pip install --upgrade pip-tools - python -m piptools compile --output-file=requirements.txt pyproject.toml - pip install -r requirements.txt - name: Install package run: pip install $PACKAGE diff --git a/nireports/__init__.py b/nireports/__init__.py index d26313e2..c71de6b1 100644 --- a/nireports/__init__.py +++ b/nireports/__init__.py @@ -27,11 +27,10 @@ try: from ._version import __version__ except ModuleNotFoundError: - from pkg_resources import DistributionNotFound, get_distribution - + from importlib.metadata import version, PackageNotFoundError try: - __version__ = get_distribution(__packagename__).version - except DistributionNotFound: - __version__ = "unknown" - del get_distribution - del DistributionNotFound + __version__ = version(__packagename__) + except PackageNotFoundError: + __version__ = "0+unknown" + del version + del PackageNotFoundError diff --git a/nireports/assembler/data/__init__.py b/nireports/assembler/data/__init__.py new file mode 100644 index 00000000..fab31927 --- /dev/null +++ b/nireports/assembler/data/__init__.py @@ -0,0 +1,182 @@ +"""Nireports assembler data files + +.. autofunction:: load + +.. automethod:: load.readable + +.. automethod:: load.as_path + +.. automethod:: load.cached + +.. autoclass:: Loader +""" +from __future__ import annotations + +import atexit +import os +from contextlib import AbstractContextManager, ExitStack +from functools import cached_property +from pathlib import Path +from types import ModuleType +from typing import Union + +try: + from functools import cache +except ImportError: # PY38 + from functools import lru_cache as cache + +try: # Prefer backport to leave consistency to dependency spec + from importlib_resources import as_file, files +except ImportError: + from importlib.resources import as_file, files # type: ignore + +try: # Prefer stdlib so Sphinx can link to authoritative documentation + from importlib.resources.abc import Traversable +except ImportError: + from importlib_resources.abc import Traversable + +__all__ = ["load"] + + +class Loader: + """A loader for package files relative to a module + + This class wraps :mod:`importlib.resources` to provide a getter + function with an interpreter-lifetime scope. For typical packages + it simply passes through filesystem paths as :class:`~pathlib.Path` + objects. For zipped distributions, it will unpack the files into + a temporary directory that is cleaned up on interpreter exit. + + This loader accepts a fully-qualified module name or a module + object. + + Expected usage:: + + '''Data package + + .. autofunction:: load_data + + .. automethod:: load_data.readable + + .. automethod:: load_data.as_path + + .. automethod:: load_data.cached + ''' + + from nireports.assembler.data import Loader + + load_data = Loader(__package__) + + :class:`~Loader` objects implement the :func:`callable` interface + and generate a docstring, and are intended to be treated and documented + as functions. + + For greater flexibility and improved readability over the ``importlib.resources`` + interface, explicit methods are provided to access resources. + + +---------------+----------------+------------------+ + | On-filesystem | Lifetime | Method | + +---------------+----------------+------------------+ + | `True` | Interpreter | :meth:`cached` | + +---------------+----------------+------------------+ + | `True` | `with` context | :meth:`as_path` | + +---------------+----------------+------------------+ + | `False` | n/a | :meth:`readable` | + +---------------+----------------+------------------+ + + It is also possible to use ``Loader`` directly:: + + from nireports.assembler.data import Loader + + Loader(other_package).readable('data/resource.ext').read_text() + + with Loader(other_package).as_path('data') as pkgdata: + # Call function that requires full Path implementation + func(pkgdata) + + # contrast to + + from importlib_resources import files, as_file + + files(other_package).joinpath('data/resource.ext').read_text() + + with as_file(files(other_package) / 'data') as pkgdata: + func(pkgdata) + + .. automethod:: readable + + .. automethod:: as_path + + .. automethod:: cached + """ + + def __init__(self, anchor: Union[str, ModuleType]): + self._anchor = anchor + self.files = files(anchor) + self.exit_stack = ExitStack() + atexit.register(self.exit_stack.close) + # Allow class to have a different docstring from instances + self.__doc__ = self._doc + + @cached_property + def _doc(self): + """Construct docstring for instances + + Lists the public top-level paths inside the location, where + non-public means has a `.` or `_` prefix or is a 'tests' + directory. + """ + top_level = sorted( + os.path.relpath(p, self.files) + "/"[: p.is_dir()] + for p in self.files.iterdir() + if p.name[0] not in (".", "_") and p.name != "tests" + ) + doclines = [ + f"Load package files relative to ``{self._anchor}``.", + "", + "This package contains the following (top-level) files/directories:", + "", + *(f"* ``{path}``" for path in top_level), + ] + + return "\n".join(doclines) + + def readable(self, *segments) -> Traversable: + """Provide read access to a resource through a Path-like interface. + + This file may or may not exist on the filesystem, and may be + efficiently used for read operations, including directory traversal. + + This result is not cached or copied to the filesystem in cases where + that would be necessary. + """ + return self.files.joinpath(*segments) + + def as_path(self, *segments) -> AbstractContextManager[Path]: + """Ensure data is available as a :class:`~pathlib.Path`. + + This method generates a context manager that yields a Path when + entered. + + This result is not cached, and any temporary files that are created + are deleted when the context is exited. + """ + return as_file(self.files.joinpath(*segments)) + + @cache + def cached(self, *segments) -> Path: + """Ensure data is available as a :class:`~pathlib.Path`. + + Any temporary files that are created remain available throughout + the duration of the program, and are deleted when Python exits. + + Results are cached so that multiple calls do not unpack the same + data multiple times, but the cache is sensitive to the specific + argument(s) passed. + """ + return self.exit_stack.enter_context(as_file(self.files.joinpath(*segments))) + + __call__ = cached + + +load = Loader(__package__) diff --git a/nireports/assembler/report.py b/nireports/assembler/report.py index a861d9ba..eb858601 100644 --- a/nireports/assembler/report.py +++ b/nireports/assembler/report.py @@ -27,19 +27,19 @@ from collections import defaultdict from itertools import compress from pathlib import Path -from yaml import safe_load as load import jinja2 +import yaml from bids.layout import BIDSLayout, BIDSLayoutIndexer, add_config_paths from bids.layout.writing import build_path -from pkg_resources import resource_filename as pkgrf +from nireports.assembler import data from nireports.assembler.reportlet import Reportlet # Add a new figures spec try: - add_config_paths(figures=pkgrf("nireports.assembler", "data/nipreps.json")) + add_config_paths(figures=data.load("nipreps.json")) except ValueError as e: if "Configuration 'figures' already exists" != str(e): raise @@ -85,10 +85,10 @@ class Report: .. testsetup:: - >>> from pkg_resources import resource_filename >>> from shutil import copytree >>> from bids.layout import BIDSLayout - >>> test_data_path = Path(resource_filename('nireports', 'assembler/data')) + >>> from nireports.assembler import data + >>> test_data_path = data.load() >>> testdir = Path(tmpdir) >>> data_dir = copytree( ... test_data_path / 'tests' / 'work', @@ -277,7 +277,7 @@ def __init__( # Initialize structuring elements self.sections = [] - bootstrap_file = Path(bootstrap_file or pkgrf("nireports.assembler", "data/default.yml")) + bootstrap_file = Path(bootstrap_file or data.load("default.yml")) bootstrap_text = [] @@ -299,7 +299,7 @@ def __init__( bootstrap_text.append(line) # Load report schema (settings YAML file) - settings = load("\n".join(bootstrap_text)) + settings = yaml.safe_load("\n".join(bootstrap_text)) # Set the output path self.out_filename = Path(out_filename) @@ -309,7 +309,7 @@ def __init__( # Path to the Jinja2 template self.template_path = ( Path(settings["template_path"]) if "template_path" in settings - else Path(pkgrf("nireports.assembler", "data/report.tpl")).absolute() + else data.load("report.tpl").absolute() ) if not self.template_path.is_absolute(): @@ -329,7 +329,7 @@ def __init__( # Override plugins specified in the bootstrap with arg if plugins is not None or (plugins := settings.get("plugins", [])): settings["plugins"] = [ - load(Path(pkgrf(plugin["module"], plugin["path"])).read_text()) + yaml.safe_load(data.Loader(plugin["module"]).readable(plugin["path"]).read_text()) for plugin in plugins ] @@ -343,7 +343,7 @@ def index(self, config): """ # Initialize a BIDS layout _indexer = BIDSLayoutIndexer( - config_filename=pkgrf("nireports.assembler", "data/nipreps.json"), + config_filename=data.load("nipreps.json"), index_metadata=False, validate=False, ) diff --git a/nireports/assembler/reportlet.py b/nireports/assembler/reportlet.py index 35324142..a69c421a 100644 --- a/nireports/assembler/reportlet.py +++ b/nireports/assembler/reportlet.py @@ -26,8 +26,8 @@ from pathlib import Path from uuid import uuid4 import re -from pkg_resources import resource_filename as pkgrf from nipype.utils.filemanip import copyfile +from nireports.assembler import data from nireports.assembler.misc import dict2html, read_crashfile @@ -118,15 +118,15 @@ class Reportlet: .. testsetup:: - >>> from pkg_resources import resource_filename as pkgrf >>> from shutil import copytree >>> from bids.layout import BIDSLayout, add_config_paths - >>> test_data_path = pkgrf('nireports', 'assembler/data/tests/work') + >>> from nireports.assembler import data + >>> test_data_path = data.load('tests', 'work') >>> testdir = Path(tmpdir) >>> data_dir = copytree(test_data_path, str(testdir / 'work')) >>> out_figs = testdir / 'out' / 'fmriprep' >>> try: - ... add_config_paths(figures=pkgrf("nireports.assembler", "data/nipreps.json")) + ... add_config_paths(figures=data.load("nipreps.json")) ... except ValueError as e: ... if "Configuration 'figures' already exists" != str(e): ... raise @@ -382,7 +382,7 @@ def __init__(self, layout, config=None, out_dir=None, bids_filters=None, metadat ) text = f"""
{text}

Bibliography

-
{Path(pkgrf(*bibfile)).read_text()}
+
{data.Loader(bibfile[0]).readable(bibfile[1]).read_text()}
""" tab_title = "LaTeX" diff --git a/nireports/assembler/tests/test_report.py b/nireports/assembler/tests/test_report.py index 8efb0fc9..b8abde33 100644 --- a/nireports/assembler/tests/test_report.py +++ b/nireports/assembler/tests/test_report.py @@ -30,13 +30,14 @@ import matplotlib.pyplot as plt import pytest +import yaml from bids.layout import BIDSLayout from bids.layout.writing import build_path -from pkg_resources import resource_filename as pkgrf -from yaml import safe_load as load from nireports.assembler.report import Report +from nireports.assembler import data + summary_meta = { "Summary": { @@ -118,16 +119,13 @@ def bids_sessions(tmpdir_factory): @pytest.fixture() def test_report1(): - test_data_path = pkgrf( - "nireports", - os.path.join("assembler", "data", "tests", "work", "reportlets"), - ) + test_data_path = data.load("tests", "work", "reportlets") out_dir = tempfile.mkdtemp() return Report( Path(out_dir) / "nireports", "fakeuuid", - reportlets_dir=Path(test_data_path) / "nireports", + reportlets_dir=test_data_path / "nireports", metadata={"summary-meta": summary_meta}, subject="01", ) @@ -186,11 +184,8 @@ def test_process_orderings_small( expected_value_combos, ): report = test_report1 - layout_root = pkgrf( - "nireports", - os.path.join("assembler", "data", "tests", "work", "reportlets"), - ) - layout = BIDSLayout(Path(layout_root) / "nireports", config="figures", validate=False) + layout_root = data.load("tests", "work", "reportlets") + layout = BIDSLayout(layout_root / "nireports", config="figures", validate=False) entities, value_combos = report._process_orderings(orderings, layout.get()) assert entities == expected_entities @@ -260,8 +255,7 @@ def test_generated_reportlets(bids_sessions, ordering): reportlets_dir=Path(bids_sessions) / "nireports", subject="01", ) - config = Path(pkgrf("nireports.assembler", "data/default.yml")) - settings = load(config.read_text()) + settings = yaml.safe_load(data.load.readable("default.yml").read_text()) settings["root"] = str(Path(bids_sessions) / "nireports") settings["out_dir"] = str(out_dir / "nireports") settings["run_uuid"] = "fakeuuid" diff --git a/nireports/assembler/tools.py b/nireports/assembler/tools.py index 38761b79..75b5b4d0 100644 --- a/nireports/assembler/tools.py +++ b/nireports/assembler/tools.py @@ -41,9 +41,9 @@ def run_reports( -------- .. testsetup:: - >>> from pkg_resources import resource_filename >>> from shutil import copytree - >>> test_data_path = resource_filename('nireports', 'assembler/data/tests/work') + >>> from nireports.assembler import data + >>> test_data_path = data.load('tests', 'work') >>> testdir = Path(tmpdir) >>> data_dir = copytree(test_data_path, str(testdir / 'work')) >>> (testdir / 'nireports').mkdir(parents=True, exist_ok=True) diff --git a/pyproject.toml b/pyproject.toml index ace7e0ca..702e1b42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ license = {file = "LICENSE"} requires-python = ">=3.8" dependencies = [ - 'importlib_resources; python_version < "3.9"', + "importlib_resources >= 5.12; python_version < '3.12'", "matplotlib >= 3.4.2", "nibabel >= 3.0.1", "nilearn >= 0.5.2",