Repository: sunpy/sunpy

Issue:
Cannot run PyInstaller executables importing SunPy
Tested with the simple script 
```
from sunpy.coordinates.sun import earth_distance
print(earth_distance())
```
then did `pyinstaller test1.py` and it finished successfully. But starting executable I got the following:
```
Traceback (most recent call last):
  File "test1.py", line 1, in <module>
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "PyInstaller/loader/pyimod03_importers.py", line 531, in exec_module
  File "sunpy/__init__.py", line 51, in <module>
  File "sunpy/__init__.py", line 41, in _get_bibtex
FileNotFoundError: [Errno 2] No such file or directory: '/home/yakob/projects/sdc/grisview.dist/tmp/dist/test1/sunpy/CITATION.rst'
[138952] Failed to execute script test1
```
Checking astropy and sunpy folders in PyInstaller dist folder, looks like sunpy is really missing files (there is only `io` folder). Copying `CITATION.rst` to the folder leads to another missing file error. Tested scripts using AstroPy and they work well with PyInstaller. Done on a 'clean' venv environment (and conda' one as well).
Python 3.8.5
AstroPy 4.2.1
SunPy 2.1.4
OS: Kubuntu 20.04



Discussion:
Hey @taradrop thanks for opening an issue.

While I think it would be pretty easy to patch over this specific error, it appears to be a symptom of a more worrying issue which is that pyinstaller seems to not be copying the package data. This will probably cause a few less obvious things to break deeper in sunpy.

I know astropy did a fair bit of work to support pyinstaller, and added some tests to their CI to keep it working. I think it would be great for someone to do this for sunpy as well. If you are interested in doing this I would be more than happy to help you.

It seems that astropy has a reasonably complex test hook to run the tests inside a pyinstaller build:
https://github.com/astropy/astropy/blob/main/.pyinstaller/run_astropy_tests.py

Also it looks like pyinstaller has a hook for astropy: https://github.com/pyinstaller/pyinstaller/pull/4274/files

I think this probably will require a little work and a decent amount of understanding of pyinstaller that I don't have. It's possible someone from astropy could give us some advice if we get stuck.
> Hey @taradrop thanks for opening an issue.
> 
> While I think it would be pretty easy to patch over this specific error, it appears to be a symptom of a more worrying issue which is that pyinstaller seems to not be copying the package data. This will probably cause a few less obvious things to break deeper in sunpy.
> 
> I know astropy did a fair bit of work to support pyinstaller, and added some tests to their CI to keep it working. I think it would be great for someone to do this for sunpy as well. If you are interested in doing this I would be more than happy to help you.
> 
> It seems that astropy has a reasonably complex test hook to run the tests inside a pyinstaller build:
> https://github.com/astropy/astropy/blob/main/.pyinstaller/run_astropy_tests.py
> 
> Also it looks like pyinstaller has a hook for astropy: https://github.com/pyinstaller/pyinstaller/pull/4274/files
> 
> I think this probably will require a little work and a decent amount of understanding of pyinstaller that I don't have. It's possible someone from astropy could give us some advice if we get stuck.

Would adding a hook for sunpy with all the hidden imports and non python files to PyInstaller be enough?
I don't know, but looks likely.
> I don't know, but looks likely.

I wrote a hook very similar to that of astropy's and ran it locally, it seems to be working fine with a few tests I ran.
A PR with the hook and a suitable test script for the CI would be great. :rocket: 
> A PR with the hook and a suitable test script for the CI would be great. 🚀

The hook belongs to PyInstaller though, how would the PR be need to be made?
Can you not define local hooks? Of course adding support upstream would be great as well.
> Can you not define local hooks? Of course adding support upstream would be great as well.

Hooks can be added locally but I'm a bit confused on how we want the test script to be structured. Are you familiar with how Astropy's 
https://github.com/astropy/astropy/blob/b23aa47a613ce1e08ef6e7aa60dc1e9480d1d292/.pyinstaller/run_astropy_tests.py
is written?

Resolving patch:
diff --git a/.pyinstaller/hooks/hook-sunpy.py b/.pyinstaller/hooks/hook-sunpy.py
new file mode 100644
--- /dev/null
+++ b/.pyinstaller/hooks/hook-sunpy.py
@@ -0,0 +1,13 @@
+from PyInstaller.utils.hooks import collect_data_files, collect_entry_point, collect_submodules, copy_metadata
+
+# Imports needed to run tests
+datas, hiddenimports = collect_entry_point("pytest11")
+hiddenimports += collect_submodules('numpy.distutils')
+hiddenimports += collect_submodules('distutils')
+hiddenimports += ['sunpy.data.data_manager.tests.mocks']
+
+hiddenimports += collect_submodules('sunpy')
+
+datas += collect_data_files("sunpy")
+datas += collect_data_files("drms")
+datas += copy_metadata("sunpy")


Test patch:
diff --git a/.pyinstaller/run_sunpy_tests.py b/.pyinstaller/run_sunpy_tests.py
new file mode 100644
--- /dev/null
+++ b/.pyinstaller/run_sunpy_tests.py
@@ -0,0 +1,81 @@
+import os
+import sys
+import shutil
+
+import pytest
+
+# Skipping these tests that take check the name of the current module (which ends up starting with
+# sunpy_tests rather than sunpy).
+# asdf path issue with PyInstaller as well.
+
+if getattr(sys, 'frozen', False):
+    # Running in a bundle
+    bundle_dir = sys._MEIPASS
+
+    SKIP_TESTS = [
+        'test_saveframe',
+        'test_saveframe_arr',
+        'test_genericmap_basic',
+        'test_genericmap_mask',
+        'test_attr_metamagic',
+        'test_main_nonexisting_module',
+        'test_main_stdlib_module',
+        'test_origin',
+        'test_find_dependencies',
+        'test_missing_dependencies_by_extra',
+        'test_hgc_100',
+        'test_missing_dependencies_by_extra',
+        'test_basic',
+        'test_data_manager',
+        'test_file_tampered',
+        'test_download_cache',
+        'test_skip_all',
+        'test_override_file',
+        'test_same_file_id_different_module']
+
+    sys.exit(pytest.main(['sunpy_tests',
+                          '-k ' + ' and '.join('not ' + test for test in SKIP_TESTS)]))
+else:
+    ROOT = os.path.join(os.path.dirname(__file__), '../')
+
+    for root, dirnames, files in os.walk(os.path.join(ROOT, 'sunpy')):
+        for dirname in dirnames:
+            final_dir = os.path.relpath(
+                os.path.join(
+                    root.replace(
+                        'sunpy',
+                        'sunpy_tests'),
+                    dirname),
+                ROOT)
+            # We only copy over 'tests' directories, but not sunpy/tests (only
+            # sunpy/tests/tests) since that is not just a directory with tests.
+            if dirname == 'tests' and not root.endswith('sunpy'):
+                shutil.copytree(os.path.join(root, dirname), final_dir, dirs_exist_ok=True)
+            else:
+                # Create empty __init__.py files so that 'sunpy_tests' still
+                # behaves like a single package, otherwise pytest gets confused
+                # by the different conftest.py files.
+                init_filename = os.path.join(final_dir, '__init__.py')
+                if not os.path.exists(os.path.join(final_dir, '__init__.py')):
+                    os.makedirs(final_dir, exist_ok=True)
+                    with open(os.path.join(final_dir, '__init__.py'), 'w') as f:
+                        f.write("#")
+        # Copy over all conftest.py files
+        for file in files:
+            if file == 'conftest.py':
+                final_file = os.path.relpath(
+                    os.path.join(
+                        root.replace(
+                            'sunpy',
+                            'sunpy_tests'),
+                        file),
+                    ROOT)
+                shutil.copy2(os.path.join(root, file), final_file)
+
+        # Add the top-level __init__.py file
+        with open(os.path.join('sunpy_tests', '__init__.py'), 'w') as f:
+            f.write("#")
+
+        # Copy the top-level conftest.py
+        shutil.copy2(os.path.join(ROOT, 'sunpy', 'conftest.py'),
+                     os.path.join('sunpy_tests', 'conftest.py'))
diff --git a/sunpy/data/data_manager/tests/test_manager.py b/sunpy/data/data_manager/tests/test_manager.py
--- a/sunpy/data/data_manager/tests/test_manager.py
+++ b/sunpy/data/data_manager/tests/test_manager.py
@@ -15,7 +15,7 @@ def test_basic(storage, downloader, data_function):
     assert Path(storage._store[0]['file_path']).name == ('sunpy.test_file')
 
 
-def test_cache(manager, storage, downloader, data_function):
+def test_download_cache(manager, storage, downloader, data_function):
     """
     Test calling function multiple times does not redownload.
     """