Repository: sunpy/sunpy

Issue:
Map factory does not support `pathlib.Path.glob` 
### Describe the feature

`pathlib.Path.glob` produces a generator for the list of files that match the glob pattern in a particularly directory. However, this not a supported pattern for the map factory. This was surprising to me since we support `pathlib.Path` objects as well as more traditional glob patterns as well as lists.

As an example, I would expect the following to work,

```python
import pathlib
import sunpy.map
sunpy.map.Map(pathlib.Path.home().glob('*.fits'))
```
but instead it throws the following exception
```python-traceback
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In [16], line 1
----> 1 sunpy.map.Map(pathlib.Path.home().glob('*.fits'))

File ~/Documents/codes/sunpy/sunpy/map/map_factory.py:254, in MapFactory.__call__(self, composite, sequence, silence_errors, *args, **kwargs)
    228 def __call__(self, *args, composite=False, sequence=False, silence_errors=False, **kwargs):
    229     """ Method for running the factory. Takes arbitrary arguments and
    230     keyword arguments and passes them to a sequence of pre-registered types
    231     to determine which is the correct Map-type to build.
   (...)
    252     as `memmap` for FITS files.
    253     """
--> 254     data_header_pairs = self._parse_args(*args, silence_errors=silence_errors, **kwargs)
    255     new_maps = list()
    257     # Loop over each registered type and check to see if WidgetType
    258     # matches the arguments.  If it does, use that type.

File ~/Documents/codes/sunpy/sunpy/map/map_factory.py:180, in MapFactory._parse_args(self, silence_errors, *args, **kwargs)
    178 for arg in args:
    179     try:
--> 180         data_header_pairs += self._parse_arg(arg, **kwargs)
    181     except NoMapsInFileError as e:
    182         if not silence_errors:

File ~/Documents/codes/sunpy/sunpy/util/functools.py:18, in seconddispatch.<locals>.wrapper(*args, **kwargs)
     17 def wrapper(*args, **kwargs):
---> 18     return dispatcher.dispatch(args[1].__class__)(*args, **kwargs)

File ~/Documents/codes/sunpy/sunpy/map/map_factory.py:195, in MapFactory._parse_arg(self, arg, **kwargs)
    189 @seconddispatch
    190 def _parse_arg(self, arg, **kwargs):
    191     """
    192     Take a factory input and parse into (data, header) pairs.
    193     Must return a list, even if only one pair is returned.
    194     """
--> 195     raise ValueError(f"Invalid input: {arg}")

ValueError: Invalid input: <generator object Path.glob at 0x104dc71b0>
```

### Proposed solution

Add this as a possible input to the map factory.

Discussion:
nabobalis: This will need a unit test as well to check that pathlib.glob works. 
Jett-Code: > Looks like this change has failed on the CI, this will need debugging.
> 
> This will need a unit test as well to check that pathlib.glob works.

Could you please guide me on how to write unit tests for this?

Jett-Code: > You will want to add extra unit tests to https://github.com/sunpy/sunpy/blob/main/sunpy/util/tests/test_util.py to ensure that we skip over strings correctly.

There are already two unit test cases for string, list, and tuples. Do you want me to add extra on top of that?
`lst = ['a', 'b', [], (['c', 'd']), tuple(), ['e']]`



nabobalis: > There are already two unit test cases for string, list, and tuples. Do you want me to add extra on top of that? `lst = ['a', 'b', [], (['c', 'd']), tuple(), ['e']]`

If the tests for those are already there, then its ok. But what if you just pass in a string?

The other unit tests are failing with these changes, we need to debug this.

Jett-Code: > 


github-actions[bot]: Hello :wave:, Thanks for your contribution to sunpy!
I have marked this pull request as stale because there hasn't had any activity in five months. If you are still working on this, or if it's waiting on a maintainer to look at it then please let us know and we will keep it open. Please add a comment with: @sunpy/sunpy-developers to get someone's attention.
If nobody comments on this pull request for another month, it will be closed.
nabobalis: Hello @Jett-Code, sorry for not getting back to you after a long time. WOuld you have some time soon to add a few unit tests for this? 
Jett-Code: > Hello @Jett-Code, sorry for not getting back to you after a long time. WOuld you have some time soon to add a few unit tests for this?

Sure, on it!

nabobalis: Actually I think that one glob test is enough, I don't know why I asked for more.

Could you rebase this and we will get this reviewed! 
Jett-Code: Hey, can you help me with failing test cases?

nabobalis: > Hey, can you help me with failing test cases?

You mean the two failing checks? What would you like help with? 

The gallery one looks like a real failure due to this code. 
Unsure about the online one.

We just need to run those failing examples and debug why they are failing now and see if they are due to the code changes in this pull request. 
Jett-Code: The examples mentioned under unexpected failing examples are working perfectly fine when executed locally, is there any other way of finding the error?


Patch:
diff --git a/changelog/7024.feature.rst b/changelog/7024.feature.rst
new file mode 100644
index 000000000..b6faa75d2
--- /dev/null
+++ b/changelog/7024.feature.rst
@@ -0,0 +1,1 @@
+Added the ability for `sunpy.map.Map` to load files from a generator.
diff --git a/sunpy/timeseries/timeseries_factory.py b/sunpy/timeseries/timeseries_factory.py
index 9aefed354..0e99bb4d8 100644
--- a/sunpy/timeseries/timeseries_factory.py
+++ b/sunpy/timeseries/timeseries_factory.py
@@ -343,7 +343,7 @@ class TimeSeriesFactory(BasicRegistrationFactory):
         """
         Parse a single arg and return a list of timeseries.
         """
-        raise NoMatchError("File not found or invalid input")
+        raise NoMatchError(f"File not found or invalid input with {arg} and {kwargs}")
 
     @_parse_arg.register(GenericTimeSeries)
     def _parse_ts(self, ts, **kwargs):
diff --git a/sunpy/util/functools.py b/sunpy/util/functools.py
index 4065ed5b6..d289fb7b1 100644
--- a/sunpy/util/functools.py
+++ b/sunpy/util/functools.py
@@ -11,7 +11,6 @@ def seconddispatch(func):
     """
     A variant of `functools.singledispatch` which dispatches on type of the second argument.
     """
-
     dispatcher = functools.singledispatch(func)
 
     def wrapper(*args, **kwargs):
diff --git a/sunpy/util/util.py b/sunpy/util/util.py
index 3cd4c8f33..d469453a5 100644
--- a/sunpy/util/util.py
+++ b/sunpy/util/util.py
@@ -10,6 +10,7 @@ from base64 import b64encode
 from shutil import get_terminal_size
 from itertools import chain, count
 from collections import UserList
+from collections.abc import Iterator
 
 __all__ = ['unique', 'replacement_filename', 'expand_list',
            'expand_list_generator', 'dict_keys_same', 'hash_file', 'get_width',
@@ -92,12 +93,13 @@ def expand_list(inp):
     ----------
     * https://stackoverflow.com/questions/2185822/expanding-elements-in-a-list/2185971#2185971
     """
-    return [item for item in expand_list_generator(inp)]
+    return list(expand_list_generator(inp))
 
 
 def expand_list_generator(inp):
     for item in inp:
-        if isinstance(item, tuple | list | UserList):
+        # parfive.Results are UserList
+        if isinstance(item, (list | tuple | UserList | Iterator)):
             yield from expand_list_generator(item)
         else:
             yield item


Test patch:
diff --git a/sunpy/map/tests/test_map_factory.py b/sunpy/map/tests/test_map_factory.py
index f8d813b22..5351fb217 100644
--- a/sunpy/map/tests/test_map_factory.py
+++ b/sunpy/map/tests/test_map_factory.py
@@ -83,15 +83,22 @@ def test_patterns(eit_fits_directory):
     maps = sunpy.map.Map(os.fspath(eit_fits_directory))
     assert isinstance(maps, list)
     assert ([isinstance(amap, sunpy.map.GenericMap) for amap in maps])
+
     # Test that returned maps are sorted
     files_sorted = sorted(list(eit_fits_directory.glob('*')))
     maps_sorted = [sunpy.map.Map(os.fspath(f)) for f in files_sorted]
-    assert all([m.date == m_s.date for m, m_s in zip(maps, maps_sorted)])
+    assert all(m.date == m_s.date for m, m_s in zip(maps, maps_sorted))
 
     # Pathlib
     path = pathlib.Path(AIA_171_IMAGE)
     aiamap = sunpy.map.Map(path)
+
+    # Generator
+    maps = sunpy.map.Map(eit_fits_directory.glob('*.fits'))
+    assert isinstance(maps, list)
     assert isinstance(aiamap, sunpy.map.GenericMap)
+
+    # A directory
     maps = sunpy.map.Map(eit_fits_directory)
     assert isinstance(maps, list)
     assert ([isinstance(amap, sunpy.map.GenericMap) for amap in maps])
@@ -101,16 +108,19 @@ def test_patterns(eit_fits_directory):
     maps = sunpy.map.Map(pattern)
     assert isinstance(maps, list)
     assert ([isinstance(amap, sunpy.map.GenericMap) for amap in maps])
+
     # Test that returned maps are sorted
     files_sorted = sorted(list(pathlib.Path(pattern).parent.glob('*')))
     maps_sorted = [sunpy.map.Map(os.fspath(f)) for f in files_sorted]
-    assert all([m.date == m_s.date for m, m_s in zip(maps, maps_sorted)])
+    assert all(m.date == m_s.date for m, m_s in zip(maps, maps_sorted))
+
     # Single character wildcard (?)
     pattern = os.path.join(eit_fits_directory, "efz20040301.0?0010_s.fits")
     maps = sunpy.map.Map(pattern)
     assert isinstance(maps, list)
     assert len(maps) == 7
     assert ([isinstance(amap, sunpy.map.GenericMap) for amap in maps])
+
     # Character ranges
     pattern = os.path.join(eit_fits_directory, "efz20040301.0[2-6]0010_s.fits")
     maps = sunpy.map.Map(pattern)
diff --git a/sunpy/util/tests/test_util.py b/sunpy/util/tests/test_util.py
index 1504b6efd..ad42c7573 100644
--- a/sunpy/util/tests/test_util.py
+++ b/sunpy/util/tests/test_util.py
@@ -1,4 +1,10 @@
+import pytest
+
+from astropy.io import fits
+
+from sunpy.data.test import get_test_filepath
 from sunpy.util import util
+from sunpy.util.parfive_helpers import Results
 
 
 def test_unique():
@@ -49,9 +55,21 @@ def test_expand_list():
     assert util.expand_list(lst) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
 
 
-def test_expand_list_generator():
-    lst = ['a', 'b', [], (['c', 'd']), tuple(), ['e']]
-    assert list(util.expand_list_generator(lst)) == ['a', 'b', 'c', 'd', 'e']
+@pytest.mark.parametrize(('input_data', 'expected_output'), (
+    (['a1234', 'b', [], (['c', 'd']), (), ['e'], b'fghj'], ['a1234', 'b', 'c', 'd', 'e', b'fghj']),
+    (iter(['c', 'd']), ['c', 'd']),
+    (zip(['1', '2'], ['3', '4']), ['1', '3', '2', '4']),
+    (Results(["a", "b", "c"]), ["a", "b", "c"]),
+    ([open(get_test_filepath('aia_171_level1.fits'), 'rb')], open(get_test_filepath('aia_171_level1.fits'), 'rb').readlines())
+))
+def test_expand_list_generator(input_data, expected_output):
+    assert list(util.expand_list_generator(input_data)) == expected_output
+
+
+@pytest.mark.filterwarnings("ignore:Invalid 'BLANK' keyword in header")
+def test_expand_list_generator_map(aia171_test_map):
+    before = after = [aia171_test_map.data, aia171_test_map.wcs, fits.getheader(get_test_filepath('aia_171_level1.fits'))]
+    assert list(util.expand_list_generator(before)) == after
 
 
 def test_partial_key_match():