Skip to content

Commit 9f5462b

Browse files
authored
Merge pull request #109 from templateflow/mnt/py312
MNT: Python 3.12 support, drop Python 3.7 and pkg_resources
2 parents 0e3795c + 9bd9d27 commit 9f5462b

File tree

10 files changed

+205
-30
lines changed

10 files changed

+205
-30
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
source /tmp/venv/bin/activate
2626
pip install -U pip
2727
pip install -r /tmp/src/templateflow/requirements.txt
28-
pip install "datalad ~= 0.11.8"
28+
pip install datalad
2929
pip install "setuptools>=45" "setuptools_scm >= 6.2" nipreps-versions build twine codecov
3030
3131
- run:

.github/workflows/pythonpackage.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,12 @@ jobs:
6262
needs: build
6363
strategy:
6464
matrix:
65-
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
65+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
6666
mode: ['wheel']
6767
include:
68-
- {python-version: '3.9', mode: 'repo'}
69-
- {python-version: '3.9', mode: 'sdist'}
70-
- {python-version: '3.9', mode: 'editable'}
68+
- {python-version: '3.11', mode: 'repo'}
69+
- {python-version: '3.11', mode: 'sdist'}
70+
- {python-version: '3.11', mode: 'editable'}
7171

7272
env:
7373
TEMPLATEFLOW_HOME: /tmp/home

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pybids>=0.15.2
2+
importlib_resources >= 5.7; python_version < '3.11'
23
requests
34
tqdm
45
pytest

setup.cfg

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ classifiers =
44
Intended Audience :: Science/Research
55
Topic :: Scientific/Engineering :: Image Recognition
66
License :: OSI Approved :: Apache Software License
7-
Programming Language :: Python :: 3.7
87
Programming Language :: Python :: 3.8
98
Programming Language :: Python :: 3.9
109
Programming Language :: Python :: 3.10
10+
Programming Language :: Python :: 3.11
11+
Programming Language :: Python :: 3.12
1112
description = TemplateFlow Python Client - TemplateFlow is the Zone of neuroimaging templates.
1213
license = Apache-2.0
1314
license_file = LICENSE
@@ -24,13 +25,14 @@ project_urls =
2425
Source Code = https://github.com/templateflow/python-client
2526

2627
[options]
27-
python_requires = >= 3.7
28+
python_requires = >= 3.8
2829
setup_requires =
2930
setuptools >= 45
3031
setuptools_scm >= 6.2
3132
wheel
3233
install_requires =
3334
pybids >= 0.15.2
35+
importlib_resources >= 5.7; python_version < '3.11'
3436
requests
3537
tqdm
3638
test_requires =

templateflow/__init__.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66
try:
77
from ._version import __version__
88
except ModuleNotFoundError:
9-
from pkg_resources import get_distribution, DistributionNotFound
10-
9+
from importlib.metadata import version, PackageNotFoundError
1110
try:
12-
__version__ = get_distribution(__packagename__).version
13-
except DistributionNotFound:
14-
__version__ = "unknown"
15-
del get_distribution
16-
del DistributionNotFound
11+
__version__ = version(__packagename__)
12+
except PackageNotFoundError:
13+
__version__ = "0+unknown"
14+
del version
15+
del PackageNotFoundError
1716

1817
import os
1918
from . import api

templateflow/_loader.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Resource loader module
2+
3+
.. autoclass:: Loader
4+
"""
5+
from __future__ import annotations
6+
7+
import atexit
8+
import os
9+
from contextlib import AbstractContextManager, ExitStack
10+
from functools import cached_property
11+
from pathlib import Path
12+
from types import ModuleType
13+
from typing import Union
14+
15+
try:
16+
from functools import cache
17+
except ImportError: # PY38 # pragma: no cover
18+
from functools import lru_cache as cache
19+
20+
try: # Prefer backport to leave consistency to dependency spec
21+
from importlib_resources import as_file, files
22+
except ImportError: # pragma: no cover
23+
from importlib.resources import as_file, files # type: ignore
24+
25+
try: # Prefer stdlib so Sphinx can link to authoritative documentation
26+
from importlib.resources.abc import Traversable
27+
except ImportError: # pragma: no cover
28+
from importlib_resources.abc import Traversable
29+
30+
__all__ = ["Loader"]
31+
32+
33+
class Loader:
34+
"""A loader for package files relative to a module
35+
36+
This class wraps :mod:`importlib.resources` to provide a getter
37+
function with an interpreter-lifetime scope. For typical packages
38+
it simply passes through filesystem paths as :class:`~pathlib.Path`
39+
objects. For zipped distributions, it will unpack the files into
40+
a temporary directory that is cleaned up on interpreter exit.
41+
42+
This loader accepts a fully-qualified module name or a module
43+
object.
44+
45+
Expected usage::
46+
47+
'''Data package
48+
49+
.. autofunction:: load_data
50+
51+
.. automethod:: load_data.readable
52+
53+
.. automethod:: load_data.as_path
54+
55+
.. automethod:: load_data.cached
56+
'''
57+
58+
from templateflow._loader import Loader
59+
60+
load_data = Loader(__package__)
61+
62+
:class:`~Loader` objects implement the :func:`callable` interface
63+
and generate a docstring, and are intended to be treated and documented
64+
as functions.
65+
66+
For greater flexibility and improved readability over the ``importlib.resources``
67+
interface, explicit methods are provided to access resources.
68+
69+
+---------------+----------------+------------------+
70+
| On-filesystem | Lifetime | Method |
71+
+---------------+----------------+------------------+
72+
| `True` | Interpreter | :meth:`cached` |
73+
+---------------+----------------+------------------+
74+
| `True` | `with` context | :meth:`as_path` |
75+
+---------------+----------------+------------------+
76+
| `False` | n/a | :meth:`readable` |
77+
+---------------+----------------+------------------+
78+
79+
It is also possible to use ``Loader`` directly::
80+
81+
from templateflow._loader import Loader
82+
83+
Loader(other_package).readable('data/resource.ext').read_text()
84+
85+
with Loader(other_package).as_path('data') as pkgdata:
86+
# Call function that requires full Path implementation
87+
func(pkgdata)
88+
89+
# contrast to
90+
91+
from importlib_resources import files, as_file
92+
93+
files(other_package).joinpath('data/resource.ext').read_text()
94+
95+
with as_file(files(other_package) / 'data') as pkgdata:
96+
func(pkgdata)
97+
98+
.. automethod:: readable
99+
100+
.. automethod:: as_path
101+
102+
.. automethod:: cached
103+
"""
104+
105+
def __init__(self, anchor: Union[str, ModuleType]):
106+
self._anchor = anchor
107+
self.files = files(anchor)
108+
self.exit_stack = ExitStack()
109+
atexit.register(self.exit_stack.close)
110+
# Allow class to have a different docstring from instances
111+
self.__doc__ = self._doc
112+
113+
@cached_property
114+
def _doc(self):
115+
"""Construct docstring for instances
116+
117+
Lists the public top-level paths inside the location, where
118+
non-public means has a `.` or `_` prefix or is a 'tests'
119+
directory.
120+
"""
121+
top_level = sorted(
122+
os.path.relpath(p, self.files) + "/"[: p.is_dir()]
123+
for p in self.files.iterdir()
124+
if p.name[0] not in (".", "_") and p.name != "tests"
125+
)
126+
doclines = [
127+
f"Load package files relative to ``{self._anchor}``.",
128+
"",
129+
"This package contains the following (top-level) files/directories:",
130+
"",
131+
*(f"* ``{path}``" for path in top_level),
132+
]
133+
134+
return "\n".join(doclines)
135+
136+
def readable(self, *segments) -> Traversable:
137+
"""Provide read access to a resource through a Path-like interface.
138+
139+
This file may or may not exist on the filesystem, and may be
140+
efficiently used for read operations, including directory traversal.
141+
142+
This result is not cached or copied to the filesystem in cases where
143+
that would be necessary.
144+
"""
145+
return self.files.joinpath(*segments)
146+
147+
def as_path(self, *segments) -> AbstractContextManager[Path]:
148+
"""Ensure data is available as a :class:`~pathlib.Path`.
149+
150+
This method generates a context manager that yields a Path when
151+
entered.
152+
153+
This result is not cached, and any temporary files that are created
154+
are deleted when the context is exited.
155+
"""
156+
return as_file(self.files.joinpath(*segments))
157+
158+
@cache
159+
def cached(self, *segments) -> Path:
160+
"""Ensure data is available as a :class:`~pathlib.Path`.
161+
162+
Any temporary files that are created remain available throughout
163+
the duration of the program, and are deleted when Python exits.
164+
165+
Results are cached so that multiple calls do not unpack the same
166+
data multiple times, but the cache is sensitive to the specific
167+
argument(s) passed.
168+
"""
169+
return self.exit_stack.enter_context(as_file(self.files.joinpath(*segments)))
170+
171+
__call__ = cached

templateflow/conf/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
from pathlib import Path
66
from contextlib import suppress
77
from functools import wraps
8+
from .._loader import Loader
9+
10+
load_data = Loader(__package__)
811

912
TF_DEFAULT_HOME = Path.home() / ".cache" / "templateflow"
1013
TF_HOME = Path(getenv("TEMPLATEFLOW_HOME", str(TF_DEFAULT_HOME)))

templateflow/conf/_s3.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
"""Tooling to handle S3 downloads."""
22
from pathlib import Path
33
from tempfile import mkstemp
4-
from pkg_resources import resource_filename
4+
5+
from . import load_data
56

67
TF_SKEL_URL = (
78
"https://raw.githubusercontent.com/templateflow/python-client/"
89
"{release}/templateflow/conf/templateflow-skel.{ext}"
910
).format
10-
TF_SKEL_PATH = Path(resource_filename("templateflow", "conf/templateflow-skel.zip"))
11-
TF_SKEL_MD5 = Path(
12-
resource_filename("templateflow", "conf/templateflow-skel.md5")
13-
).read_text()
11+
TF_SKEL_PATH = load_data("templateflow-skel.zip")
12+
TF_SKEL_MD5 = load_data.readable("templateflow-skel.md5").read_text()
1413

1514

1615
def update(dest, local=True, overwrite=True, silent=False):

templateflow/conf/bids.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Extending pyBIDS for querying TemplateFlow."""
2-
from pkg_resources import resource_filename
32
from bids.layout import BIDSLayout, add_config_paths
43

5-
add_config_paths(templateflow=resource_filename("templateflow", "conf/config.json"))
4+
from . import load_data
5+
6+
add_config_paths(templateflow=load_data("config.json"))
67

78

89
class Layout(BIDSLayout):

templateflow/tests/test_version.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Test _version.py."""
22
import sys
33
from collections import namedtuple
4-
from pkg_resources import DistributionNotFound
4+
from importlib.metadata import PackageNotFoundError
55
from importlib import reload
66
import templateflow
77

@@ -18,14 +18,13 @@ class _version:
1818

1919

2020
def test_version_scm1(monkeypatch):
21-
"""Retrieve the version via pkg_resources."""
21+
"""Retrieve the version via importlib.metadata."""
2222
monkeypatch.setitem(sys.modules, "templateflow._version", None)
2323

24-
def _dist(name):
25-
Distribution = namedtuple("Distribution", ["name", "version"])
26-
return Distribution(name, "success")
24+
def _ver(name):
25+
return "success"
2726

28-
monkeypatch.setattr("pkg_resources.get_distribution", _dist)
27+
monkeypatch.setattr("importlib.metadata.version", _ver)
2928
reload(templateflow)
3029
assert templateflow.__version__ == "success"
3130

@@ -35,8 +34,8 @@ def test_version_scm2(monkeypatch):
3534
monkeypatch.setitem(sys.modules, "templateflow._version", None)
3635

3736
def _raise(name):
38-
raise DistributionNotFound("No get_distribution mock")
37+
raise PackageNotFoundError("No get_distribution mock")
3938

40-
monkeypatch.setattr("pkg_resources.get_distribution", _raise)
39+
monkeypatch.setattr("importlib.metadata.version", _raise)
4140
reload(templateflow)
42-
assert templateflow.__version__ == "unknown"
41+
assert templateflow.__version__ == "0+unknown"

0 commit comments

Comments
 (0)