Repository: sunpy/sunpy

Issue:
system_info shows that all optional dependancies are part of the extra all rather than the more specific one
```
>>> import sunpy
>>> sunpy.system_info()
==============================
sunpy Installation Information
==============================

General
#######
OS: Arch Linux (rolling, Linux 5.16.14-arch1-1)
Arch: 64bit, ()
sunpy: 3.1.5
Installation path: /home/stuart/.virtualenvs/sunpy-minimal/lib/python3.10/site-packages

Required Dependencies
#####################
parfive: 1.5.1
numpy: 1.22.3
astropy: 5.0.2
packaging: 21.3

Optional Dependencies
#####################
asdf: Missing, need asdf>=2.6.0; extra == "all"
beautifulsoup4: Missing, need beautifulsoup4>=4.8.0; extra == "all"
cdflib: Missing, need cdflib!=0.4.0,>=0.3.19; extra == "all"
dask: Missing, need dask[array]>=2.0.0; extra == "all"
drms: Missing, need drms>=0.6.1; extra == "all"
glymur: Missing, need glymur!=0.9.0,!=0.9.5,>=0.8.18; extra == "all"
h5netcdf: Missing, need h5netcdf>=0.8.1; extra == "all"
h5py: Missing, need h5py>=3.1.0; extra == "all"
matplotlib: Missing, need matplotlib>=3.2.0; extra == "all"
mpl-animators: Missing, need mpl-animators>=1.0.0; extra == "all"
pandas: Missing, need pandas>=1.0.0; extra == "all"
python-dateutil: Missing, need python-dateutil>=2.8.0; extra == "all"
reproject: Missing, need reproject; extra == "all"
scikit-image: Missing, need scikit-image<0.19,>=0.16.0; extra == "all"
scipy: Missing, need scipy>=1.3.0; extra == "all"
sqlalchemy: Missing, need sqlalchemy>=1.3.4; extra == "all"
tqdm: 4.63.0
zeep: Missing, need zeep>=3.4.0; extra == "all"
```

Discussion:

Patch:
diff --git a/.pep8speaks.yml b/.pep8speaks.yml
deleted file mode 100644
index f845da350..000000000
--- a/.pep8speaks.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-scanner:
-  diff_only: False
-
-pycodestyle:
-  max-line-length: 100
-  exclude:
-    - setup.py
-    - docs/conf.py
-    - sunpy/__init__.py
-    - sunpy/extern/
-  ignore:
-    - E226
-    - E501
-    - W503
-    - W504
-
-descending_issues_order: True
-
-message:
-  opened:
-    header: "Hello @{name}! Thanks for opening this PR. "
-    footer: "Do see the [Hitchhiker's guide to code style](https://goo.gl/hqbW4r)"
-  updated:
-    header: "Hello @{name}! Thanks for updating this PR. "
-    footer: ""
-  no_errors: "There are currently no PEP 8 issues detected in this Pull Request. Cheers!"
diff --git a/MANIFEST.in b/MANIFEST.in
index d10ccd251..9dc53df20 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,24 +1,21 @@
-# Exclude specific files
-# All files which are tracked by git and not explicitly excluded here are included by setuptools_scm
 exclude .codecov.yaml
-exclude .mailmap
 exclude .editorconfig
 exclude .gitattributes
 exclude .gitignore
-exclude .pep8speaks.yml
+exclude .mailmap
 exclude .pre-commit-config.yaml
+exclude .readthedocs.yaml
 exclude .test_package_pins.txt
 exclude .zenodo.json
-exclude azure-pipelines.yml
+exclude asv.conf.json
+exclude CITATION.cff
 exclude readthedocs.yml
-exclude rtd-environment.yml
-# Prune folders
+
 prune .circleci
 prune .github
 prune .jupyter
+prune benchmarks
 prune binder
 prune changelog
 prune tools
-# This subpackage is only used in development checkouts and should not be
-# included in built tarballs
 prune sunpy/_dev
diff --git a/changelog/6011.bugfix.rst b/changelog/6011.bugfix.rst
new file mode 100644
index 000000000..e5f317c01
--- /dev/null
+++ b/changelog/6011.bugfix.rst
@@ -0,0 +1,1 @@
+Fixed `.system_info` so it returns the extra group when an optional dependency is missing.
diff --git a/pyproject.toml b/pyproject.toml
index d7ee23eec..5e28f304f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
 [build-system]
 requires = [
-         "setuptools",
-         "setuptools_scm",
+         "setuptools>=56,!=61.0.0",
+         "setuptools_scm[toml]>=6.2",
          "wheel",
          "oldest-supported-numpy",
          "extension-helpers"
diff --git a/sunpy/util/sysinfo.py b/sunpy/util/sysinfo.py
index 8bb5d1c7e..1bdcf390f 100644
--- a/sunpy/util/sysinfo.py
+++ b/sunpy/util/sysinfo.py
@@ -5,6 +5,7 @@ import platform
 from collections import defaultdict
 from importlib.metadata import PackageNotFoundError, version, requires, distribution
 
+from packaging.markers import Marker
 from packaging.requirements import Requirement
 
 import sunpy.extern.distro as distro
@@ -25,20 +26,53 @@ def get_requirements(package):
     -------
     `dict`
         A dictionary of requirements with keys being the extra requirement group names.
+        The values are a nested dictionary with keys being the package names and
+        values being the `packaging.requirements.Requirement` objects.
     """
     requirements: list = requires(package)
-    requires_dict = defaultdict(list)
+    requires_dict = defaultdict(dict)
     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)
+            group = "required"
+        # De-duplicate (the same package could appear more than once in the extra == 'all' group)
+        if package_name in requires_dict[group]:
+            continue
+        requires_dict[group][package_name] = req
     return requires_dict
 
 
+def resolve_requirement_versions(package_versions):
+    """
+    Resolves a list of requirements for the same package.
+
+    Given a list of package details in the form of `packaging.requirements.Requirement`
+    objects, combine the specifier, extras, url and marker information to create
+    a new requirement object.
+    """
+    resolved = Requirement(str(package_versions[0]))
+
+    for package_version in package_versions[1:]:
+        resolved.specifier = resolved.specifier & package_version.specifier
+        resolved.extras = resolved.extras.union(package_version.extras)
+        resolved.url = resolved.url or package_version.url
+        if resolved.marker and package_version.marker:
+            resolved.marker = Marker(f"{resolved.marker} or {package_version.marker}")
+        elif package_version.marker:
+            resolved.marker = package_version.marker
+
+    return resolved
+
+
+def format_requirement_string(requirement):
+    formatted_string = f"Missing {requirement}"
+    formatted_string = formatted_string.replace("or extra ==", "or").strip()
+    return formatted_string
+
+
 def find_dependencies(package="sunpy", extras=None):
     """
     List installed and missing dependencies.
@@ -49,17 +83,20 @@ def find_dependencies(package="sunpy", extras=None):
     """
     requirements = get_requirements(package)
     installed_requirements = {}
-    missing_requirements = {}
+    missing_requirements = defaultdict(list)
     extras = extras or ["required"]
     for group in requirements:
         if group not in extras:
             continue
-        for package in requirements[group]:
+        for package, package_details in requirements[group].items():
             try:
                 package_version = version(package)
                 installed_requirements[package] = package_version
             except PackageNotFoundError:
-                missing_requirements[package] = f"Missing {package}"
+                missing_requirements[package].append(package_details)
+    for package, package_versions in missing_requirements.items():
+        missing_requirements[package] = format_requirement_string(
+            resolve_requirement_versions(package_versions))
     return missing_requirements, installed_requirements
 
 
@@ -80,13 +117,27 @@ def missing_dependencies_by_extra(package="sunpy", exclude_extras=None):
     return missing_dependencies
 
 
+def get_extra_groups(groups, exclude_extras):
+    return list(set(groups) - set(exclude_extras))
+
+
+def get_keys_list(dictionary, sort=True):
+    keys = [*dictionary.keys()]
+    if sort:
+        return sorted(keys)
+    return keys
+
+
 def system_info():
     """
     Prints ones' system info in an "attractive" fashion.
     """
-    base_reqs = get_requirements("sunpy")["required"]
-    extra_reqs = get_requirements("sunpy")["all"]
-    missing_packages, installed_packages = find_dependencies(package="sunpy", extras=["required", "all"])
+    requirements = get_requirements("sunpy")
+    groups = get_keys_list(requirements)
+    extra_groups = get_extra_groups(groups, ['all', 'dev'])
+    base_reqs = get_keys_list(requirements['required'])
+    extra_reqs = get_keys_list(requirements['all'])
+    missing_packages, installed_packages = find_dependencies(package="sunpy", extras=extra_groups)
     extra_prop = {"System": platform.system(),
                   "Arch": f"{platform.architecture()[0]}, ({platform.processor()})",
                   "Python": platform.python_version(),
diff --git a/tox.ini b/tox.ini
index 1fd3b3e5a..5815c0fc9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,7 +5,7 @@ envlist =
     codestyle
     base_deps
 requires =
-    setuptools >= 30.3.0
+    setuptools >=56, !=61.0.0
     pip >= 19.3.1
     tox-pypi-filter >= 0.12
 isolated_build = true


Test patch:
diff --git a/sunpy/util/tests/test_sysinfo.py b/sunpy/util/tests/test_sysinfo.py
index ad3ea7eeb..bba6c2ce5 100644
--- a/sunpy/util/tests/test_sysinfo.py
+++ b/sunpy/util/tests/test_sysinfo.py
@@ -1,4 +1,12 @@
-from sunpy.util.sysinfo import find_dependencies, missing_dependencies_by_extra, system_info
+from packaging.requirements import Requirement
+
+from sunpy.util.sysinfo import (
+    find_dependencies,
+    format_requirement_string,
+    missing_dependencies_by_extra,
+    resolve_requirement_versions,
+    system_info,
+)
 
 
 def test_find_dependencies():
@@ -17,8 +25,19 @@ def test_find_dependencies():
 
 def test_missing_dependencies_by_extra():
     missing = missing_dependencies_by_extra()
-    assert sorted(list(missing.keys())) == sorted(['all', 'asdf', 'required', 'dask', 'database', 'dev', 'docs',
-                                                   'image', 'jpeg2000', 'map', 'net', 'tests', 'timeseries',
+    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(['asdf', 'required', 'dask', 'database', 'dev', 'docs',
@@ -26,6 +45,35 @@ def test_missing_dependencies_by_extra():
                                                    'visualization'])
 
 
+def test_resolve_requirement_versions():
+    package1 = Requirement('test-package[ext1]>=1.1.1; extra == "group1"')
+    package2 = Requirement('test-package[ext2]<=2.0.0; extra == "group2"')
+    assert str(resolve_requirement_versions([package1, package2])) == str(Requirement(
+        'test-package[ext1,ext2]<=2.0.0,>=1.1.1; extra == "group1" or extra == "group2"'))
+
+    package3 = Requirement('test-package==1.1.0; extra == "group3"')
+    package4 = Requirement('test-package==1.1.0; extra == "group4"')
+    assert str(resolve_requirement_versions([package3, package4])) == str(
+        Requirement('test-package==1.1.0; extra == "group3" or extra == "group4"'))
+
+    package5 = Requirement('test-package; extra == "group5"')
+    package6 = Requirement('test-package[ext3]@https://foo.com')
+    assert str(resolve_requirement_versions([package5, package6])) == str(
+        Requirement('test-package[ext3]@ https://foo.com ; extra == "group5"'))
+
+
+def test_format_requirement_string():
+    package1 = Requirement('test-package[ext1]>=1.1.1; extra == "group1"')
+    assert format_requirement_string(package1) == 'Missing test-package[ext1]>=1.1.1; extra == "group1"'
+
+    package2 = Requirement('test-package>=1.1.1; extra == "group1" or extra == "group2" or extra == "group3"')
+    assert format_requirement_string(
+        package2) == 'Missing test-package>=1.1.1; extra == "group1" or "group2" or "group3"'
+
+    package3 = Requirement('test-package>=1.1.1')
+    assert format_requirement_string(package3) == 'Missing test-package>=1.1.1'
+
+
 def test_system_info(capsys):
     system_info()
     captured = capsys.readouterr()