diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 246f18009..da15d1c00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,14 @@ jobs: default_python: '3.10' envs: | - linux: coverage + name: Python 3.10 coverage + python-version: 3.10 + - linux: coverage + name: Python 3.9 coverage + python-version: 3.9 + - linux: coverage + name: Python 3.8 coverage + python-version: 3.8 coverage: codecov test: @@ -49,8 +57,6 @@ jobs: # Any env name which does not start with `pyXY` will use this Python version. default_python: '3.9' envs: | - - linux: py38 - - linux: py39 - macos: py39 - windows: py39 diff --git a/CHANGES.rst b/CHANGES.rst index 9f28bbd3f..655dbce2a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,7 @@ `~asdf.search.AsdfSearchResult.schema_info` method. [#1197] - Use forc ndarray flag to correctly test for fortran array contiguity [#1206] - Unpin ``jsonschema`` version and fix ``jsonschema`` deprecation warnings. [#1185] +- Replace ``pkg_resources`` with ``importlib.metadata``. [#1199] 2.13.0 (2022-08-19) ------------------- diff --git a/asdf/asdf.py b/asdf/asdf.py index 105fb7ca2..d5a132e44 100644 --- a/asdf/asdf.py +++ b/asdf/asdf.py @@ -7,7 +7,7 @@ import numpy as np from jsonschema import ValidationError -from pkg_resources import parse_version +from packaging.version import Version from . import _display as display from . import _node_info as node_info @@ -336,7 +336,7 @@ def _check_extensions(self, tree, strict=False): if installed.package_version is None or installed.package_name != extension.software["name"]: continue # Compare version in file metadata with installed version - if parse_version(installed.package_version) < parse_version(extension.software["version"]): + if Version(installed.package_version) < Version(extension.software["version"]): msg = ("File {}was created with extension {}, but older package ({}=={}) " "is installed.").format( filename, extension_description, diff --git a/asdf/entry_points.py b/asdf/entry_points.py index 0747710ce..5f18fc733 100644 --- a/asdf/entry_points.py +++ b/asdf/entry_points.py @@ -1,6 +1,10 @@ +import sys import warnings -from pkg_resources import iter_entry_points +if sys.version_info < (3, 10): + from importlib_metadata import entry_points +else: + from importlib.metadata import entry_points from .exceptions import AsdfWarning from .extension import ExtensionProxy @@ -25,7 +29,7 @@ def get_extensions(): def _list_entry_points(group, proxy_class): results = [] - entry_points = list(iter_entry_points(group=group)) + points = entry_points(group=group) # The order of plugins may be significant, since in the case of # duplicate functionality the first plugin in the list takes @@ -33,13 +37,11 @@ def _list_entry_points(group, proxy_class): # in a consistent way across systems so we explicitly sort # by package name. Plugins from this package are placed # at the end so that other packages can override them. - asdf_entry_points = [e for e in entry_points if e.dist.project_name == "asdf"] - other_entry_points = sorted( - (e for e in entry_points if e.dist.project_name != "asdf"), key=lambda e: e.dist.project_name - ) + asdf_entry_points = [e for e in points if e.dist.name == "asdf"] + other_entry_points = sorted((e for e in points if e.dist.name != "asdf"), key=lambda e: e.dist.name) for entry_point in other_entry_points + asdf_entry_points: - package_name = entry_point.dist.project_name + package_name = entry_point.dist.name package_version = entry_point.dist.version def _handle_error(e): diff --git a/asdf/fits_embed.py b/asdf/fits_embed.py index b61fdcf7d..8b223945c 100644 --- a/asdf/fits_embed.py +++ b/asdf/fits_embed.py @@ -351,10 +351,3 @@ def write_to( def update(self, all_array_storage=None, all_array_compression=None, pad_blocks=False, **kwargs): raise NotImplementedError("In-place update is not currently implemented for ASDF-in-FITS") - - self._update_asdf_extension( - all_array_storage=all_array_storage, - all_array_compression=all_array_compression, - pad_blocks=pad_blocks, - **kwargs, - ) diff --git a/asdf/tests/test_asdf.py b/asdf/tests/test_asdf.py index fc03a4227..98cb4d4c4 100644 --- a/asdf/tests/test_asdf.py +++ b/asdf/tests/test_asdf.py @@ -1,13 +1,27 @@ +import warnings + import pytest from asdf import config_context, get_config from asdf.asdf import AsdfFile, SerializationContext, open_asdf +from asdf.entry_points import get_extensions from asdf.exceptions import AsdfWarning from asdf.extension import AsdfExtensionList, ExtensionManager, ExtensionProxy from asdf.tests.helpers import assert_no_warnings, yaml_to_asdf from asdf.versioning import AsdfVersion +def test_no_warnings_get_extensions(): + """ + Smoke test for changes to the `importlib.metadata` entry points API. + """ + + with warnings.catch_warnings(): + warnings.simplefilter("error") + + get_extensions() + + class TestExtension: __test__ = False diff --git a/asdf/tests/test_entry_points.py b/asdf/tests/test_entry_points.py index f4d4e5948..6827be6e6 100644 --- a/asdf/tests/test_entry_points.py +++ b/asdf/tests/test_entry_points.py @@ -1,6 +1,11 @@ -import pkg_resources +import sys + import pytest -from pkg_resources import EntryPoint + +if sys.version_info < (3, 10): + import importlib_metadata as metadata +else: + import importlib.metadata as metadata from asdf import entry_points from asdf._version import version as asdf_package_version @@ -16,17 +21,15 @@ def mock_entry_points(): @pytest.fixture(autouse=True) def monkeypatch_entry_points(monkeypatch, mock_entry_points): - def _iter_entry_points(*, group): + def _entry_points(*, group): for candidate_group, name, func_name in mock_entry_points: if candidate_group == group: - yield EntryPoint( - name, - "asdf.tests.test_entry_points", - attrs=(func_name,), - dist=pkg_resources.get_distribution("asdf"), - ) + point = metadata.EntryPoint(name=name, group="asdf.tests.test_entry_points", value=func_name) + vars(point).update(dist=metadata.distribution("asdf")) + + yield point - monkeypatch.setattr(entry_points, "iter_entry_points", _iter_entry_points) + monkeypatch.setattr(entry_points, "entry_points", _entry_points) def resource_mappings_entry_point_successful(): @@ -49,7 +52,13 @@ def resource_mappings_entry_point_bad_element(): def test_get_resource_mappings(mock_entry_points): - mock_entry_points.append(("asdf.resource_mappings", "successful", "resource_mappings_entry_point_successful")) + mock_entry_points.append( + ( + "asdf.resource_mappings", + "successful", + "asdf.tests.test_entry_points:resource_mappings_entry_point_successful", + ) + ) mappings = entry_points.get_resource_mappings() assert len(mappings) == 2 for m in mappings: @@ -58,13 +67,21 @@ def test_get_resource_mappings(mock_entry_points): assert m.package_version == asdf_package_version mock_entry_points.clear() - mock_entry_points.append(("asdf.resource_mappings", "failing", "resource_mappings_entry_point_failing")) + mock_entry_points.append( + ("asdf.resource_mappings", "failing", "asdf.tests.test_entry_points:resource_mappings_entry_point_failing") + ) with pytest.warns(AsdfWarning, match="Exception: NOPE"): mappings = entry_points.get_resource_mappings() assert len(mappings) == 0 mock_entry_points.clear() - mock_entry_points.append(("asdf.resource_mappings", "bad_element", "resource_mappings_entry_point_bad_element")) + mock_entry_points.append( + ( + "asdf.resource_mappings", + "bad_element", + "asdf.tests.test_entry_points:resource_mappings_entry_point_bad_element", + ) + ) with pytest.warns(AsdfWarning, match="TypeError: Resource mapping must implement the Mapping interface"): mappings = entry_points.get_resource_mappings() assert len(mappings) == 2 @@ -109,7 +126,9 @@ class FauxLegacyExtension: def test_get_extensions(mock_entry_points): - mock_entry_points.append(("asdf.extensions", "successful", "extensions_entry_point_successful")) + mock_entry_points.append( + ("asdf.extensions", "successful", "asdf.tests.test_entry_points:extensions_entry_point_successful") + ) extensions = entry_points.get_extensions() assert len(extensions) == 2 for e in extensions: @@ -118,13 +137,17 @@ def test_get_extensions(mock_entry_points): assert e.package_version == asdf_package_version mock_entry_points.clear() - mock_entry_points.append(("asdf.extensions", "failing", "extensions_entry_point_failing")) + mock_entry_points.append( + ("asdf.extensions", "failing", "asdf.tests.test_entry_points:extensions_entry_point_failing") + ) with pytest.warns(AsdfWarning, match="Exception: NOPE"): extensions = entry_points.get_extensions() assert len(extensions) == 0 mock_entry_points.clear() - mock_entry_points.append(("asdf.extensions", "bad_element", "extensions_entry_point_bad_element")) + mock_entry_points.append( + ("asdf.extensions", "bad_element", "asdf.tests.test_entry_points:extensions_entry_point_bad_element") + ) with pytest.warns( AsdfWarning, match="TypeError: Extension must implement the Extension or AsdfExtension interface" ): @@ -132,7 +155,7 @@ def test_get_extensions(mock_entry_points): assert len(extensions) == 2 mock_entry_points.clear() - mock_entry_points.append(("asdf_extensions", "legacy", "LegacyExtension")) + mock_entry_points.append(("asdf_extensions", "legacy", "asdf.tests.test_entry_points:LegacyExtension")) extensions = entry_points.get_extensions() assert len(extensions) == 1 for e in extensions: @@ -142,7 +165,7 @@ def test_get_extensions(mock_entry_points): assert e.legacy is True mock_entry_points.clear() - mock_entry_points.append(("asdf_extensions", "failing", "FauxLegacyExtension")) + mock_entry_points.append(("asdf_extensions", "failing", "asdf.tests.test_entry_points:FauxLegacyExtension")) with pytest.warns(AsdfWarning, match="TypeError"): extensions = entry_points.get_extensions() assert len(extensions) == 0 diff --git a/asdf/tests/test_util.py b/asdf/tests/test_util.py index 062cc16fe..20d698bcc 100644 --- a/asdf/tests/test_util.py +++ b/asdf/tests/test_util.py @@ -99,3 +99,20 @@ def read(self, size=-1): fd = generic_io.get_file(OnlyHasAReadMethod(content)) assert util.get_file_type(fd) == expected_type assert fd.read() == content + + +def test_minversion(): + import numpy + import yaml + + good_versions = ["1.16", "1.16.1", "1.16.0.dev", "1.16dev"] + bad_versions = ["100000", "100000.2rc1"] + for version in good_versions: + assert util.minversion(numpy, version) + assert util.minversion("numpy", version) + for version in bad_versions: + assert not util.minversion(numpy, version) + assert not util.minversion("numpy", version) + + assert util.minversion(yaml, "3.1") + assert util.minversion("yaml", "3.1") diff --git a/asdf/util.py b/asdf/util.py index 964812c52..a45c86357 100644 --- a/asdf/util.py +++ b/asdf/util.py @@ -4,14 +4,23 @@ import math import re import struct +import sys import types from functools import lru_cache +from importlib import metadata from urllib.request import pathname2url import numpy as np +from packaging.version import Version from . import constants +if sys.version_info < (3, 10): + from importlib_metadata import packages_distributions +else: + from importlib.metadata import packages_distributions + + # We're importing our own copy of urllib.parse because # we need to patch it to support asdf:// URIs, but it'd # be irresponsible to do this for all users of a @@ -323,14 +332,12 @@ def get_class_name(obj, instance=True): return _CLASS_NAME_OVERRIDES.get(class_name, class_name) -def minversion(module, version, inclusive=True, version_path="__version__"): +def minversion(module, version, inclusive=True): """ Returns `True` if the specified Python module satisfies a minimum version requirement, and `False` if not. - By default this uses `pkg_resources.parse_version` to do the version - comparison if available. Otherwise it falls back on - `packaging.version.Version`. + Copied from astropy.utils.misc.minversion to avoid dependency on astropy. Parameters ---------- @@ -347,17 +354,14 @@ def minversion(module, version, inclusive=True, version_path="__version__"): inclusive : `bool` The specified version meets the requirement inclusively (i.e. ``>=``) as opposed to strictly greater than (default: `True`). - - version_path : `str` - A dotted attribute path to follow in the module for the version. - Defaults to just ``'__version__'``, which should work for most Python - modules. """ if isinstance(module, types.ModuleType): module_name = module.__name__ + module_version = getattr(module, "__version__", None) elif isinstance(module, str): module_name = module + module_version = None try: module = resolve_name(module_name) except ImportError: @@ -366,23 +370,24 @@ def minversion(module, version, inclusive=True, version_path="__version__"): raise ValueError( "module argument must be an actual imported " "module, or the import name of the module; " - "got {!r}".format(module) + f"got {repr(module)}" ) - if "." not in version_path: - have_version = getattr(module, version_path) - else: - have_version = resolve_name(".".join([module.__name__, version_path])) - - try: - from pkg_resources import parse_version - except ImportError: - from packaging.version import Version as parse_version + if module_version is None: + try: + module_version = metadata.version(module_name) + except metadata.PackageNotFoundError: + # Maybe the distribution name is different from package name. + # Calling packages_distributions is costly so we do it only + # if necessary, as only a few packages don't have the same + # distribution name. + dist_names = packages_distributions() + module_version = metadata.version(dist_names[module_name][0]) if inclusive: - return parse_version(have_version) >= parse_version(version) + return Version(module_version) >= Version(version) else: - return parse_version(have_version) > parse_version(version) + return Version(module_version) > Version(version) class InheritDocstrings(type): diff --git a/docs/conf.py b/docs/conf.py index e25feacb9..b725c6c9a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,9 +1,14 @@ from pathlib import Path import tomli -from pkg_resources import get_distribution from sphinx_asdf.conf import * # noqa: F403, F401 +try: + from importlib.metadata import distribution +except ImportError: + from importlib_metadata import distribution + + # Get configuration information from `pyproject.toml` with open(Path(__file__).parent.parent / "pyproject.toml", "rb") as configuration_file: conf = tomli.load(configuration_file) @@ -14,7 +19,7 @@ author = f"{configuration['authors'][0]['name']} <{configuration['authors'][0]['email']}>" copyright = f"{datetime.datetime.now().year}, {configuration['authors'][0]['name']}" -release = get_distribution(configuration["name"]).version +release = distribution(configuration["name"]).version # for example take major/minor version = ".".join(release.split(".")[:2]) diff --git a/pyproject.toml b/pyproject.toml index 535fc73da..b1d25746c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,10 @@ dependencies = [ 'asdf-standard >=1.0.1', 'asdf-transform-schemas >=0.2.2', 'importlib_resources >=3; python_version <"3.9"', + 'importlib-metadata >=3; python_version <"3.10"', 'jmespath >=0.6.2', 'jsonschema >=4.0.1', - 'numpy >=1.10', + 'numpy >=1.18', 'packaging >=16.0', 'pyyaml >=3.10', 'semantic_version >=2.8',