Repository: pyinstaller/pyinstaller

Issue:
All of site-packages is vendored when collecting data or binaries from single-file modules (like `appdirs`)
## Description of the issue

When packaging an application that depends on the [`appdirs`](https://pypi.org/project/appdirs/) library, if `--collect-data` or `--collect-binaries` is included in the invocation, the resulting distribution has a complete copy of the host's `site-packages`.

I'm not sure if this is a bug with PyInstaller or a problem with this particular package, but I'm filing here in the hopes of clarifying that one way or the other.

### Context information (for bug reports)

* Output of `pyinstaller --version`: 5.0.1
* Version of Python: 3.8
* Platform: Ubuntu 20.04 (also observed on Windows 10)
* How you installed Python: pyenv / official installer

* follow *all* the instructions in our "If Things Go Wrong" Guide
  (https://github.com/pyinstaller/pyinstaller/wiki/If-Things-Go-Wrong) and

### Make sure [everything is packaged correctly](https://github.com/pyinstaller/pyinstaller/wiki/How-to-Report-Bugs#make-sure-everything-is-packaged-correctly)

  * [ ] start with clean installation
  * [ ] use the latest development version
  * [ ] Run your frozen program **from a command window (shell)** — instead of double-clicking on it
  * [ ] Package your program in **--onedir mode**
  * [ ] Package **without UPX**, say: use the option `--noupx` or set `upx=False` in your .spec-file
  * [ ] Repackage you application in **verbose/debug mode**. For this, pass the option `--debug` to `pyi-makespec` or `pyinstaller` or use `EXE(..., debug=1, ...)` in your .spec file.


### A minimal example program which shows the error

### Good case
```
$ echo 'print("test program")' > test.py
$ python3 -m PyInstaller test.py
...
$ tree -L 2 dist/test/
dist/test/
├── base_library.zip
├── lib-dynload
│   ├── _asyncio.cpython-38-x86_64-linux-gnu.so
│   ├── _bz2.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_cn.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_hk.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_iso2022.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_jp.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_kr.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_tw.cpython-38-x86_64-linux-gnu.so
│   ├── _contextvars.cpython-38-x86_64-linux-gnu.so
│   ├── _ctypes.cpython-38-x86_64-linux-gnu.so
│   ├── _decimal.cpython-38-x86_64-linux-gnu.so
│   ├── _hashlib.cpython-38-x86_64-linux-gnu.so
│   ├── _lzma.cpython-38-x86_64-linux-gnu.so
│   ├── _multibytecodec.cpython-38-x86_64-linux-gnu.so
│   ├── _multiprocessing.cpython-38-x86_64-linux-gnu.so
│   ├── _opcode.cpython-38-x86_64-linux-gnu.so
│   ├── _posixshmem.cpython-38-x86_64-linux-gnu.so
│   ├── _queue.cpython-38-x86_64-linux-gnu.so
│   ├── _ssl.cpython-38-x86_64-linux-gnu.so
│   ├── mmap.cpython-38-x86_64-linux-gnu.so
│   ├── readline.cpython-38-x86_64-linux-gnu.so
│   ├── resource.cpython-38-x86_64-linux-gnu.so
│   └── termios.cpython-38-x86_64-linux-gnu.so
├── libbz2.so.1.0
├── libcrypto.so.1.1
├── libexpat.so.1
├── libffi.so.7
├── liblzma.so.5
├── libmpdec.so.2
├── libpython3.8.so.1.0
├── libreadline.so.8
├── libssl.so.1.1
├── libtinfo.so.6
├── libz.so.1
└── test

1 directory, 36 files
```

### Bad case
```
$ echo 'print("test program")' > test.py
$ python3 -m PyInstaller --collect-data appdirs test.py
...
$ tree -L 2 dist/test/
dist/test/
├── base_library.zip
├── lib-dynload
│   ├── _asyncio.cpython-38-x86_64-linux-gnu.so
│   ├── _bz2.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_cn.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_hk.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_iso2022.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_jp.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_kr.cpython-38-x86_64-linux-gnu.so
│   ├── _codecs_tw.cpython-38-x86_64-linux-gnu.so
│   ├── _contextvars.cpython-38-x86_64-linux-gnu.so
│   ├── _ctypes.cpython-38-x86_64-linux-gnu.so
│   ├── _decimal.cpython-38-x86_64-linux-gnu.so
│   ├── _hashlib.cpython-38-x86_64-linux-gnu.so
│   ├── _lzma.cpython-38-x86_64-linux-gnu.so
│   ├── _multibytecodec.cpython-38-x86_64-linux-gnu.so
│   ├── _multiprocessing.cpython-38-x86_64-linux-gnu.so
│   ├── _opcode.cpython-38-x86_64-linux-gnu.so
│   ├── _posixshmem.cpython-38-x86_64-linux-gnu.so
│   ├── _queue.cpython-38-x86_64-linux-gnu.so
│   ├── _ssl.cpython-38-x86_64-linux-gnu.so
│   ├── mmap.cpython-38-x86_64-linux-gnu.so
│   ├── readline.cpython-38-x86_64-linux-gnu.so
│   ├── resource.cpython-38-x86_64-linux-gnu.so
│   └── termios.cpython-38-x86_64-linux-gnu.so
├── libbz2.so.1.0
├── libcrypto.so.1.1
├── libexpat.so.1
├── libffi.so.7
├── liblzma.so.5
├── libmpdec.so.2
├── libpython3.8.so.1.0
├── libreadline.so.8
├── libssl.so.1.1
├── libtinfo.so.6
├── libz.so.1
├── site-packages
│   ├── PyInstaller
│   ├── _pyinstaller_hooks_contrib
│   ├── altgraph-0.17.2.dist-info
│   ├── appdirs-1.4.4.dist-info
│   ├── pip-20.0.2.dist-info
│   ├── pkg_resources-0.0.0.dist-info
│   ├── pyinstaller-5.0.1.dist-info
│   ├── pyinstaller_hooks_contrib-2022.4.dist-info
│   ├── setuptools
│   └── setuptools-44.0.0.dist-info
└── test

12 directories, 36 files
```

### non-packaging reproduction

Output is truncated, but running `collect_data_files()` on the package returns a list of the entire site.

```
$ python3  # in an environment with appdirs installed
Python 3.8.10 (default, Mar 15 2022, 12:22:08)
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from PyInstaller.utils.hooks import collect_data_files
>>> collect_data_files("appdirs")
[('/home/snoopjedi/scratch/pyinstaller_repro/venv/lib/python3.8/site-packages/setuptools-44.0.0.dist-info/AUTHORS.txt', 'site-packages/setuptools-44.0.0.dist-info'),  ... ]
```


Discussion:
I discovered this because of a (pretty terrible) hack in an application that collects pretty much every dependency "just in case." I can special-case that hack around `appdirs`, but I'm curious about the nature of this duplicating behavior too.
Wow! I'm amazed nobody has seen this sooner. `collect_data_files("package")` pretty much works as *collect everything under `os.path.dirname(__import__("package").__file__)`*. This is specific to `appdirs` only because it's just a plain module rather than a package.
> I discovered this because of a (pretty terrible) hack in an application that collects pretty much every dependency "just in case." 

Be wary of this route. If it didn't transpire to cause as many problems as it solves, we'd offer an option for this.
> > I discovered this because of a (pretty terrible) hack in an application that collects pretty much every dependency "just in case."
> 
> Be wary of this route. If it didn't transpire to cause as many problems as it solves, we'd offer an option for this.

Yea, I'm coming to find that it may not be worth the "just in case" provision, I should probably just list out the things I want guarantees about and deal with updating it in the future if need be.

> Wow! I'm amazed nobody has seen this sooner. collect_data_files("package") pretty much works as collect everything under os.path.dirname(__import__("package").__file__). This is specific to appdirs only because it's just a plain module rather than a package.

Aha! Thanks very much, that makes the failure (?) mode pretty clear 😄
It looks like both `collect_data_files` and `collect_dynamic_libs` need a `is_package()` check, similar to the one in [collect_submodules](https://github.com/pyinstaller/pyinstaller/blob/0de9d6cf1701689c53161610acdab143a76d40b5/PyInstaller/utils/hooks/__init__.py#L589-L591).

Resolving patch:
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
@@ -680,11 +680,19 @@ def collect_dynamic_libs(package, destdir=None):
 
     :param destdir: Relative path to ./dist/APPNAME where the libraries should be put.
     """
+    logger.debug('Collecting dynamic libraries for %s' % package)
+
     # Accept only strings as packages.
     if not isinstance(package, compat.string_types):
         raise TypeError('package must be a str')
 
-    logger.debug('Collecting dynamic libraries for %s' % package)
+    # Skip a module which is not a package.
+    if not is_package(package):
+        logger.warning(
+            "collect_dynamic_libs - skipping library collection for module '%s' as it is not a package.", package
+        )
+        return []
+
     pkg_base, pkg_dir = get_package_paths(package)
     # Walk through all file in the given package, looking for dynamic libraries.
     dylibs = []
@@ -740,6 +748,11 @@ def collect_data_files(package, include_py_files=False, subdir=None, excludes=No
     if not isinstance(package, compat.string_types):
         raise TypeError('package must be a str')
 
+    # Skip a module which is not a package.
+    if not is_package(package):
+        logger.warning("collect_data_files - skipping data collection for module '%s' as it is not a package.", package)
+        return []
+
     # Compute the root path for the provided patckage.
     pkg_base, pkg_dir = get_package_paths(package)
     if subdir:


Test patch: