Repository: sunpy/sunpy

Issue:
Replace use of pkg_resources with importlib.metadata
Setuptools' `pkg_resources` is no longer recommended and from Python 3.7+ we should be using `importlib.metadata`: https://docs.python.org/3/library/importlib.metadata.html#distribution-versions


Discussion:
Man the API of `importlib.metadata` is garbage. 

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -6,24 +6,8 @@
 import sys
 import datetime
 import warnings
-from pkg_resources import get_distribution
 from packaging.version import Version
 
-# -- Check for dependencies ----------------------------------------------------
-doc_requires = get_distribution("sunpy").requires(extras=("docs",))
-missing_requirements = []
-for requirement in doc_requires:
-    try:
-        get_distribution(requirement)
-    except Exception as e:
-        missing_requirements.append(requirement.name)
-if missing_requirements:
-    print(
-        f"The {' '.join(missing_requirements)} package(s) could not be found and "
-        "is needed to build the documentation, please install the 'docs' requirements."
-    )
-    sys.exit(1)
-
 # -- Read the Docs Specific Configuration --------------------------------------
 # This needs to be done before sunpy is imported
 on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
@@ -34,6 +18,16 @@
     os.environ['LC_ALL'] = 'C'
     os.environ['HIDE_PARFIVE_PROGESS'] = 'True'
 
+# -- Check for dependencies ----------------------------------------------------
+from sunpy.util import missing_dependencies_by_extra  # NOQA
+missing_requirements = missing_dependencies_by_extra("sunpy")["docs"]
+if missing_requirements:
+    print(
+        f"The {' '.join(missing_requirements.keys())} package(s) could not be found and "
+        "is needed to build the documentation, please install the 'docs' requirements."
+    )
+    sys.exit(1)
+
 # -- Non stdlib imports --------------------------------------------------------
 import ruamel.yaml as yaml  # NOQA
 from sphinx_gallery.sorting import ExplicitOrder  # NOQA
diff --git a/sunpy/util/sysinfo.py b/sunpy/util/sysinfo.py
--- a/sunpy/util/sysinfo.py
+++ b/sunpy/util/sysinfo.py
@@ -2,14 +2,43 @@
 This module provides functions to retrieve system information.
 """
 import platform
+from collections import defaultdict
+from importlib.metadata import PackageNotFoundError, version, requires, distribution
 
-from pkg_resources import get_distribution
+from packaging.requirements import Requirement
 
 import sunpy.extern.distro as distro
 
 __all__ = ['system_info', 'find_dependencies', 'missing_dependencies_by_extra']
 
 
+def get_requirements(package):
+    """
+    This wraps `importlib.metadata.requires` to not be garbage.
+
+    Parameters
+    ----------
+    package : str
+        Package you want requirements for.
+
+    Returns
+    -------
+    `dict`
+        A dictionary of requirements with keys being the extra requirement group names.
+    """
+    requirements: list = requires(package)
+    requires_dict = defaultdict(list)
+    for requirement in requirements:
+        req = Requirement(requirement)
+        package_name, package_marker = req.name, req.marker
+        if package_marker and "extra ==" in str(package_marker):
+            group = str(package_marker).split("extra == ")[1].strip('"').strip("'").strip()
+            requires_dict[group].append(package_name)
+        else:
+            requires_dict["required"].append(package_name)
+    return requires_dict
+
+
 def find_dependencies(package="sunpy", extras=None):
     """
     List installed and missing dependencies.
@@ -18,17 +47,19 @@ def find_dependencies(package="sunpy", extras=None):
     which should be installed to match the requirements and return any which are
     missing.
     """
-    if not extras:
-        extras = tuple()
-    requires = get_distribution(package).requires(extras=extras)
+    requirements = get_requirements(package)
     installed_requirements = {}
     missing_requirements = {}
-    for requirement in requires:
-        try:
-            package = get_distribution(requirement)
-            installed_requirements[package.project_name.lower()] = package.version
-        except Exception:
-            missing_requirements[requirement.name.lower()] = f"Missing, need {requirement}"
+    extras = extras or ["required"]
+    for group in requirements:
+        if group not in extras:
+            continue
+        for package in requirements[group]:
+            try:
+                package_version = version(package)
+                installed_requirements[package] = package_version
+            except PackageNotFoundError:
+                missing_requirements[package] = f"Missing {package}"
     return missing_requirements, installed_requirements
 
 
@@ -40,13 +71,12 @@ def missing_dependencies_by_extra(package="sunpy", exclude_extras=None):
     dependencies associated with no extras.
     """
     exclude_extras = exclude_extras or []
-    distribution = get_distribution(package)
-    extras = distribution.extras
-    missing_dependencies = {"required": find_dependencies(package)[0]}
-    for extra in extras:
-        if extra in exclude_extras:
+    requirements = get_requirements(package)
+    missing_dependencies = {}
+    for group in requirements.keys():
+        if group in exclude_extras:
             continue
-        missing_dependencies[extra] = find_dependencies(package, (extra,))[0]
+        missing_dependencies[group] = find_dependencies(package, [group])[0]
     return missing_dependencies
 
 
@@ -54,18 +84,14 @@ def system_info():
     """
     Prints ones' system info in an "attractive" fashion.
     """
-    base_reqs = get_distribution("sunpy").requires()
-    base_reqs = {base_req.name.lower() for base_req in base_reqs}
-    extra_reqs = get_distribution("sunpy").requires(extras=["all"])
-    extra_reqs = sorted({extra_req.name.lower() for extra_req in extra_reqs}.difference(base_reqs))
-
-    missing_packages, installed_packages = find_dependencies(package="sunpy", extras=["all"])
+    base_reqs = get_requirements("sunpy")["required"]
+    extra_reqs = get_requirements("sunpy")["all"]
+    missing_packages, installed_packages = find_dependencies(package="sunpy", extras=["required", "all"])
     extra_prop = {"System": platform.system(),
                   "Arch": f"{platform.architecture()[0]}, ({platform.processor()})",
                   "Python": platform.python_version(),
-                  "sunpy": get_distribution("sunpy").version}
+                  "sunpy": version("sunpy")}
     sys_prop = {**installed_packages, **missing_packages, **extra_prop}
-
     print("==============================")
     print("sunpy Installation Information")
     print("==============================")
@@ -82,7 +108,7 @@ def system_info():
         print("Unknown OS")
     for sys_info in ['Arch', 'sunpy']:
         print(f'{sys_info}: {sys_prop[sys_info]}')
-    print(f'Installation path: {get_distribution("sunpy").location}')
+    print(f'Installation path: {distribution("sunpy")._path}')
     print()
     print("Required Dependencies")
     print("#####################")


Test patch:
diff --git a/.test_package_pins.txt b/.test_package_pins.txt
--- a/.test_package_pins.txt
+++ b/.test_package_pins.txt
@@ -1,3 +0,0 @@
-# Avoid DeprecationWarning: SelectableGroups dict interface is deprecated. Use select.
-# Ignore in setup.cfg seems to not work 100% of the time.
-importlib-metadata<3.9.0
diff --git a/sunpy/tests/helpers.py b/sunpy/tests/helpers.py
--- a/sunpy/tests/helpers.py
+++ b/sunpy/tests/helpers.py
@@ -2,12 +2,14 @@
 import platform
 import warnings
 from pathlib import Path
+from platform import python_version
 from functools import wraps
+from importlib.metadata import entry_points
 
 import matplotlib as mpl
 import matplotlib.pyplot as plt
-import pkg_resources
 import pytest
+from packaging.version import Version
 
 import astropy
 from astropy.wcs.wcs import FITSFixedWarning
@@ -41,24 +43,32 @@
 else:
     SKIP_32 = True
 
-skip_windows = pytest.mark.skipif(platform.system() == 'Windows', reason="Windows.")
+skip_windows = pytest.mark.skipif(platform.system() == "Windows", reason="Windows.")
 skip_glymur = pytest.mark.skipif(SKIP_GLYMUR, reason="Glymur can not be imported.")
 skip_ana = pytest.mark.skipif(SKIP_ANA, reason="ANA is not available.")
-# Skip if the SunPy ASDF entry points are missing.
-asdf_entry_points = pytest.mark.skipif(not list(pkg_resources.iter_entry_points('asdf_extensions', 'sunpy')),
-                                       reason="No SunPy ASDF entry points.")
+if Version(python_version()) >= Version("3.10.0"):
+    asdf_entry_points = pytest.mark.skipif(
+        not entry_points().select(group="asdf_extensions", name="sunpy"),
+        reason="No SunPy ASDF entry points.",
+    )
+else:
+    asdf_entry_points = pytest.mark.skipif(
+        not all(
+            [
+                enter_point.name == "sunpy"
+                for enter_point in entry_points()["asdf_extensions"]
+            ]
+        ),
+        reason="No SunPy ASDF entry points.",
+    )
 
 
 @pytest.fixture
 def warnings_as_errors(request):
     warnings.simplefilter('error')
-
     request.addfinalizer(lambda *args: warnings.resetwarnings())
 
 
-new_hash_library = {}
-
-
 def get_hash_library_name():
     """
     Generate the hash library name for this env.
@@ -115,7 +125,6 @@ def wrapper(*args, **kwargs):
         res = f(*args, **kwargs)
         Fido.registry[VSOClient] = VSOClient._can_handle_query
         return res
-
     return wrapper
 
 
diff --git a/sunpy/tests/self_test.py b/sunpy/tests/self_test.py
--- a/sunpy/tests/self_test.py
+++ b/sunpy/tests/self_test.py
@@ -45,7 +45,7 @@ def self_test(*, package=None, online=False, online_only=False, figure_only=Fals
     print("\n\n")
     print("Starting sunpy self test...")
     print("Checking for packages needed to run sunpy:")
-    missing = missing_dependencies_by_extra(exclude_extras=("asdf", "dask", "dev", "all", "docs"))
+    missing = missing_dependencies_by_extra(exclude_extras=["asdf", "dask", "dev", "all", "docs"])
     test_missing = missing.pop("tests")
     printed = print_missing_dependencies_report(missing)
     if not printed:
diff --git a/sunpy/util/tests/test_sysinfo.py b/sunpy/util/tests/test_sysinfo.py
--- a/sunpy/util/tests/test_sysinfo.py
+++ b/sunpy/util/tests/test_sysinfo.py
@@ -6,14 +6,22 @@ def test_find_dependencies():
     assert missing == {}
     assert sorted(list(installed.keys())) == sorted(["astropy", "numpy", "packaging", "parfive"])
 
+    missing, installed = find_dependencies(package="sunpy", extras=["required", "all"])
+    assert missing == {}
+    assert sorted(list(installed.keys())) == sorted(['astropy', 'numpy', 'parfive', 'packaging', 'asdf',
+                                                     'dask', 'sqlalchemy', 'scikit-image', 'scipy', 'glymur',
+                                                     'matplotlib', 'mpl-animators', 'reproject',
+                                                     'beautifulsoup4', 'drms', 'python-dateutil', 'tqdm',
+                                                     'zeep', 'cdflib', 'h5netcdf', 'h5py', 'pandas'])
+
 
 def test_missing_dependencies_by_extra():
     missing = missing_dependencies_by_extra()
-    assert sorted(list(missing.keys())) == sorted(['required', 'all', 'asdf', 'dask', 'database', 'dev', 'docs',
+    assert sorted(list(missing.keys())) == sorted(['all', 'asdf', 'required', 'dask', 'database', 'dev', 'docs',
                                                    'image', 'jpeg2000', 'map', 'net', 'tests', 'timeseries',
                                                    'visualization'])
-    missing = missing_dependencies_by_extra(exclude_extras=("all",))
-    assert sorted(list(missing.keys())) == sorted(['required', 'asdf', 'dask', 'database', 'dev', 'docs',
+    missing = missing_dependencies_by_extra(exclude_extras=["all"])
+    assert sorted(list(missing.keys())) == sorted(['asdf', 'required', 'dask', 'database', 'dev', 'docs',
                                                    'image', 'jpeg2000', 'map', 'net', 'tests', 'timeseries',
                                                    'visualization'])