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:
Hi @Cadair 🙂 when I tried to run `sunpy.system_info()` on the development version of `sunpy` (instead of the latest release of v3.1.5), my Optional Dependencies section is a bit different:

<img width="340" alt="Screen Shot 2022-03-24 at 12 05 32 AM" src="https://user-images.githubusercontent.com/22362575/159840161-1063be7a-7aa0-48dc-807f-2a12f5485b23.png">

Looks like it's been updated already, but let me know if you're expecting something other than the above and I can do more digging!
Cadiar's version shows up if you have not installed the optional dependencies. In your case, they are installed, which explains the difference.
Is `asdf` or `beautifulsoup4` considered an optional dependency? They just say "missing" for me, unless I'm looking at the wrong file (`sunpy/util/sysinfo.py`)?
Oh I didn't notice them, yes they are considered optional. In your case the group that needs them is not printing alongside them.
With how `get_requirements` in `sysinfo.py` is currently implemented, only the package name is kept for missing packages, which was not the case in v3.1.5. Do they need to be added back, plus making the extra group more specific instead of all?
> Do they need to be added back, plus making the extra group more specific instead of all?

Yes. That is what we want. 


Resolving patch:
diff --git a/sunpy/util/sysinfo.py b/sunpy/util/sysinfo.py
--- a/sunpy/util/sysinfo.py
+++ b/sunpy/util/sysinfo.py
@@ -5,6 +5,7 @@
 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(),


Test patch:
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
@@ -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()