Repository: pyinstaller/pyinstaller

Issue:
Regression in scipy 1.9.1 -> 1.9.2
Although being described as just a bug fix release, an upgrade of `scipy` from `1.9.1` to `1.9.2` (via pip) breaks bundled applications.

With scipy 1.9.2 the bundled application is trying to look up a non-existent directory named "./scipy.libs", but the scipy DLLs were copied to "./scipy/_lib/".

With scipy 1.9.1 PyInstaller copies the scipy DLLs to "./scipy/.libs/", which the application successfully imports.

**Error:**
```
Traceback (most recent call last):
  File "main.py", line 1, in <module>
  File "<frozen importlib._bootstrap>", line 1007, in _find_and_load
  File "<frozen importlib._bootstrap>", line 986, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 680, in _load_unlocked
  File "PyInstaller\loader\pyimod02_importers.py", line 499, in exec_module
  File "scipy\__init__.py", line 76, in <module>
  File "scipy\__init__.py", line 67, in _delvewheel_init_patch_1_0_1
  File "os.py", line 1111, in add_dll_directory
FileNotFoundError: [WinError 2] The system cannot find the file specified: '<path_to_dist>\\scipy.libs'
```

**Minimal example:**
```python
import scipy
print(f"Scipy: {scipy}")
```

**Environment:**
 - OS: Windows
 - Python Version: 3.9
 - Version of `pyinstaller-hooks-contrib`: 2022.10
 - Version of PyInstaller 5.5


Discussion:
Moving this to main repository, since that's where the hooks for `scipy` reside. 

The problem is that 1.9.2 switched to `delvewheel`, which brings its shared libs directory outside of the package directory (`site-packages/scipy.libs` as opposed to former `site-packages/scipy/.libs`), which means it is now out of our sight and out of our mind.

Resolving patch:
diff --git a/PyInstaller/hooks/hook-scipy.py b/PyInstaller/hooks/hook-scipy.py
--- a/PyInstaller/hooks/hook-scipy.py
+++ b/PyInstaller/hooks/hook-scipy.py
@@ -13,9 +13,10 @@
 import os
 
 from PyInstaller.compat import is_win
-from PyInstaller.utils.hooks import get_module_file_attribute
+from PyInstaller.utils.hooks import get_module_file_attribute, is_module_satisfies, collect_delvewheel_libs_directory
 
 binaries = []
+datas = []
 
 # Package the DLL bundle that official scipy wheels for Windows ship The DLL bundle will either be in extra-dll on
 # windows proper and in .libs if installed on a virtualenv created from MinGW (Git-Bash for example)
@@ -26,5 +27,9 @@
         if glob.glob(dll_glob):
             binaries.append((dll_glob, "."))
 
+# Handle delvewheel-enabled win32 wheels, which have external scipy.libs directory (scipy >= 0.9.2)
+if is_module_satisfies("scipy >= 1.9.2") and is_win:
+    datas, binaries = collect_delvewheel_libs_directory('scipy', datas=datas, binaries=binaries)
+
 # collect library-wide utility extension modules
 hiddenimports = ['scipy._lib.%s' % m for m in ['messagestream', "_ccallback_c", "_fpumode"]]
diff --git a/PyInstaller/utils/hooks/__init__.py b/PyInstaller/utils/hooks/__init__.py
--- a/PyInstaller/utils/hooks/__init__.py
+++ b/PyInstaller/utils/hooks/__init__.py
@@ -1369,6 +1369,73 @@ def include_or_exclude_file(
     return True
 
 
+def collect_delvewheel_libs_directory(package_name, libdir_name=None, datas=None, binaries=None):
+    """
+    Collect data files and binaries from the .libs directory of a delvewheel-enabled python wheel. Such wheels ship
+    their shared libraries in a .libs directory that is located next to the package directory, and therefore falls
+    outside the purview of the collect_dynamic_libs() utility function.
+
+    Args:
+        package_name:
+            Name of the package (e.g., scipy).
+        libdir_name:
+            Optional name of the .libs directory (e.g., scipy.libs). If not provided, ".libs" is added to
+            ``package_name``.
+        datas:
+            Optional list of datas to which collected data file entries are added. The combined result is retuned
+            as part of the output tuple.
+        binaries:
+            Optional list of binaries to which collected binaries entries are added. The combined result is retuned
+            as part of the output tuple.
+
+    Returns:
+        tuple: A ``(datas, binaries)`` pair that should be assigned to the ``datas`` and ``binaries``, respectively.
+
+    Examples:
+        Collect the ``scipy.libs`` delvewheel directory belonging to the Windows ``scipy`` wheel::
+
+            datas, binaries = collect_delvewheel_libs_directory("scipy")
+
+        When the collected entries should be added to existing ``datas`` and ``binaries`` listst, the following form
+        can be used to avoid using intermediate temporary variables and merging those into existing lists::
+
+            datas, binaries = collect_delvewheel_libs_directory("scipy", datas=datas, binaries=binaries)
+
+    .. versionadded:: 5.6
+    """
+
+    datas = datas or []
+    binaries = binaries or []
+
+    if libdir_name is None:
+        libdir_name = package_name + '.libs'
+
+    # delvewheel is applicable only to Windows wheels
+    if not compat.is_win:
+        return datas, binaries
+
+    # Get package's parent path
+    pkg_base, pkg_dir = get_package_paths(package_name)
+    pkg_base = Path(pkg_base)
+    libs_dir = pkg_base / libdir_name
+
+    if not libs_dir.is_dir():
+        return datas, binaries
+
+    # Collect all dynamic libs - collect them as binaries in order to facilitate proper binary dependency analysis
+    # (for example, to ensure that system-installed VC runtime DLLs are collected, if needed).
+    # As of PyInstaller 5.4, this should be safe (should not result in duplication), because binary dependency
+    # analysis attempts to preserve the DLL directory structure.
+    binaries += [(str(dll_file), str(dll_file.parent.relative_to(pkg_base))) for dll_file in libs_dir.glob('*.dll')]
+
+    # Collect the .load-order file; strictly speaking, this should be necessary only under python < 3.8, but let us
+    # collect it for completeness sake.
+    datas += [(str(load_order_file), str(load_order_file.parent.relative_to(pkg_base)))
+              for load_order_file in libs_dir.glob('.load-order*')]
+
+    return datas, binaries
+
+
 if compat.is_pure_conda:
     from PyInstaller.utils.hooks import conda as conda_support  # noqa: F401
 elif compat.is_conda:


Test patch:
diff --git a/tests/functional/test_hooks/test_scipy.py b/tests/functional/test_hooks/test_scipy.py
--- a/tests/functional/test_hooks/test_scipy.py
+++ b/tests/functional/test_hooks/test_scipy.py
@@ -15,6 +15,13 @@
 from PyInstaller.utils.tests import importorskip
 
 
+@importorskip('scipy')
+def test_scipy_toplevel(pyi_builder):
+    pyi_builder.test_source("""
+        import scipy
+    """)
+
+
 @importorskip('scipy')
 def test_scipy(pyi_builder):
     pyi_builder.test_source(