Repository: pyinstaller/pyinstaller

Issue:
issues with copy_metadata
While using `copy_metadata` to package an application that requires `pkg_resources` I have encountered the following issues:
- the `Distribution.egg_info` field is not always set (but that does not necessarily mean no egg_info file exists)
- the egg_info file basename does not always encode the project name, e.g. with `pyhook` and `pywin32` on Windows, the files are: `c:\python27\lib\site-packages\pyhook-1.5.1-py2.7-win32.egg\EGG-INFO` and `c:\python27\lib\site-packages\pywin32-219-py2.7-win32.egg\EGG-INFO`, so using `datas.extend(copy_metadata(package))` will only effectively include one of them

Here is what I've been using instead of `copy_medata` in my hooks:

``` python
def get_metadata(package_name):
    dist = pkg_resources.get_distribution(package_name)
    egg_info = '%s.egg-info' % dist.egg_name()
    metadata = dist.egg_info
    if metadata is None:
        metadata = '%s/%s' % (dist.location, egg_info)
        if not os.path.exists(metadata):
            return None
    log.info('%s metadata: %s / %s', package_name, metadata, egg_info)
    return (metadata, egg_info)
```

copy_metadata breaks requirement "extras" for packages installed as dist-info
With #1888 a change was introduced that forcibly renames all distribution metadata directories created via `copy_metadata()` to have an `.egg-info` extension.

However [pkg_resources dispatches to different implementations based on the folder extension](https://github.com/pypa/setuptools/blob/89ef5bb7e4812193d6f593d531bad36191517981/pkg_resources/__init__.py#L2947).

One consequence of that is that required packages with required extras (i.e. something like `somepkg[someextra]` in `setup_requires`) cause exceptions during runtime since the  `.egg-info` distribution loader is unable to find the required extra in the the (originally) `.dist-info` metadata format.

Example of such an exception:
```
[...]
  File "raiden/utils/__init__.py", line 245, in get_system_spec
    raiden=pkg_resources.require(raiden.__name__)[0].version,
  File "site-packages/pkg_resources/__init__.py", line 981, in require
  File "site-packages/pkg_resources/__init__.py", line 875, in resolve
  File "site-packages/pkg_resources/__init__.py", line 2679, in requires
pkg_resources.UnknownExtra: tinyrpc 0.6 has no such extra feature 'websocket'
[72924] Failed to execute script raiden-script
```


Discussion:
@benoit-pierre, thanks for filing the issue. A couple of questions:
- I'm not surprised that `pkg_resources.get_distribution(package_name).egg_info` isn't always correct, since it isn't documented. For my testing, can you provide an example of a distribution in which `egg_info` isn't defined?
- I don't think I understand what's wrong in your second point. For pywin32, `copy_metadata` seems to work for me; it reports:
  
  ```
  Python 3.4.4 (v3.4.4:737efcadf5a6, Dec 20 2015, 19:28:18) [MSC v.1600 32 bit (Intel)] on win32
  Type "help", "copyright", "credits" or "license" for more information.
  >>> from PyInstaller.utils.hooks import copy_metadata
  >>> copy_metadata('pywin32')
  [('c:\\users\\bjones\\downloads\\winpython-32bit-3.4.4.1qt5\\python-3.4.4\\lib\\site-packages\\pywin32-220.dist-info', 'pywin32-220.dist-info')]
  ```
  
  ...which is correct for my system. Does this not work on yours? If not, does updating to the latest version (220) help?
- I'm running Python 3.4, so I can't easily test `pyhook`, which seems to be Python 2 only. What output do you get for `copy_metadata('pyhook')`?

- I'm not sure when `egg_info` is not correct, I think it might depend on how a package is installed (with a distribution package manager, using pip, from source, with a dedicated installer...). On my system (Arch Linux), I have quite a few packages without `egg_info` defined:

``` python
> python2 <<\EOF
import site
from pkg_resources import find_distributions

print [d.project_name for s in site.getsitepackages() for d in find_distributions(s) if not d.egg_info]
EOF
['adns-python', 'reportlab', 'pycrypto', 'BeautifulSoup', 'Editra', 'ipaddress', 'CDDB', 'subprocess32', 'meliae', 'pytz', 'getmail', 'pygpgme', 'gmpy2', 'PySDL2', 'ufw', 'pexpect', 'pyserial', 'team', 'dnspython', 'Pyrex', 'kaa-metadata', 'cssselect', 'louis', 'virtualenv', 'python-libtorrent', 'wrapt', 'marisa', 'python-musicbrainz2', 'mutagen', 'epydoc', 'zenmap', 'greenlet', 'wxPython-common', 'pygobject', 'Magic-file-extensions', 'numpy', 'scipy', 'arandr', 'PyBluez', 'docutils', 'dogtail', 'UNKNOWN', 'pwquality', 'html5lib', 'bzr', 'bottle', 'hachoir-metadata', 'ptyprocess', 'mpmath', 'notmuch', 'configobj', 'pychecker', 'xpybutil', 'enum34', 'hachoir-core', 'lxml', 'pyxdg', 'python-sane', 'hachoir-parser', 'picard', 'include-server', 'PyPDF2', 'python-musicbrainz', 'namebench', 'hidapi', 'kaa-base', 'mercurial', 'PyYAML', 'apsw', 'pygame']
```

But using my code:

``` python
> python2 <<\EOF
import os
import site

import pkg_resources


def get_metadata(package_name):
    dist = pkg_resources.get_distribution(package_name)
    egg_info = '%s.egg-info' % dist.egg_name()
    metadata = dist.egg_info
    if metadata is None:
        metadata = '%s/%s' % (dist.location, egg_info)
        if not os.path.exists(metadata):
            return None
    return (metadata, egg_info)

packages = set(d.project_name for s in site.getsitepackages()
               for d in pkg_resources.find_distributions(s))
print [name for name in packages if not get_metadata(name)]
EOF
['zenmap', 'apsw']
```
- Again, I think this depend on a number of factor, but the egg_info file basename is not always unique.

On my Windows 10 VM install of Python 2.7:

``` python
> ./windows/helper.py run python.exe <<\EOF
import os
import site
from collections import defaultdict

from pkg_resources import find_distributions

dups = defaultdict(list)
for s in site.getsitepackages():
  for d in find_distributions(s):
    if not d.egg_info:
        continue
    dups[os.path.basename(d.egg_info)].append((d.project_name, d.egg_info))
print [dl for dl in dups.values() if len(dl) > 1]
EOF
[[('pyhook', 'c:\\python27\\lib\\site-packages\\pyhook-1.5.1-py2.7-win32.egg\\EGG-INFO'), ('pywin32', 'c:\\python27\\lib\\site-packages\\pywin32-219-py2.7-win32.egg\\EGG-INFO')]]
```

But always using `Distribution.egg_name()` guarantees a unique filename, since it encodes both the project name and the version.

With my code, I can successfully package my application on Linux / Windows / OSX, with Python2 and a number of dependencies.

Note that both those issues are actually mentioned in the [current implementation of `copy_metadata`](https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/utils/hooks/__init__.py#L1415).

@benoit-pierre, thanks -- your test code on Arch Linux showing `egg_info` failures is very helpful. Let me clean up your code when I have a minute. Would you mind testing it for me when it's ready?

On the second point, why does `basename(dist.egg_info)` need to be unique? Or how would having a unique name change the code? I seem to be missing something here.

Let's say I need metadata for both `pyhook` and `pywin32`, with my Windows install and the current version of `copy_metadata`, this is what gets added to `datas` in my hook:

```
('c:\\python27\\lib\\site-packages\\pyhook-1.5.1-py2.7-win32.egg\\EGG-INFO', 'EGG-INFO'),
('c:\\python27\\lib\\site-packages\\pywin32-219-py2.7-win32.egg\\EGG-INFO', 'EGG-INFO'),
```

So PyInstaller will package `c:\\python27\\lib\\site-packages\\pyhook-1.5.1-py2.7-win32.egg\\EGG-INFO` as `EGG-INFO`, and then overwrite it with `c:\\python27\\lib\\site-packages\\pywin32-219-py2.7-win32.egg\\EGG-INFO`: in the resulting executable, only the metadata for `pywin32` are available.

Strange. The code in `copy_metadata` is:

``` Python
    # We want to copy from the ``metadata_dir`` to PyInstaller, leaving off its
    # prefix in ``sys.path``. The ``location`` attribute provides this prefix,
    # per https://pythonhosted.org/setuptools/pkg_resources.html#distribution-attributes.
    # For example, if ``package_name`` is ``regex``, then ``location = c:\python27\lib\site-packages``
    # and ``metadata_dir = c:\python27\lib\site-packages\regex-2015.11.09.dist-info``.
    # We should therefore return ``[ ('c:\python27\lib\site-packages\regex-2015.11.09.dist-info',
    # 'regex-2015.11.09.dist-info') ]``.
    return [ (metadata_dir, metadata_dir[len(dist.location) + len(os.sep):]) ]
```

Give your output above, I would expect that `metadata_dir` (which is `dist.egg_info`) would contain `'c:\\python27\\lib\\site-packages\\pyhook-1.5.1-py2.7-win32.egg\\EGG-INFO'` and `dist.location` would be `'c:\\python27\\lib\\site-packages`, so I would expect `copy_metadata` to return `(('c:\\python27\\lib\\site-packages\\pyhook-1.5.1-py2.7-win32.egg\\EGG-INFO', 'pyhook-1.5.1-py2.7-win32.egg\\EGG-INFO'),)`. Which of these assumptions is incorrect in your installation?

To help debug this, I've updated `copy_metadata` based on your suggestions; I've attached the [`__init__.zip`](https://github.com/pyinstaller/pyinstaller/files/179864/__init__.zip) file in which it resides. Would you run this and provide the output on your system?

`location` is set to the directory containing `EGG-INFO`, hence the problem. That's why I think using `dist.egg_name()` to get a unique filename is a better idea.

OK, thanks. I've updated my code, since yours doesn't work on my system (it seems to only support eggs, but not other formats). Would you try it?

[`__init__.zip`](https://github.com/pyinstaller/pyinstaller/files/179906/__init__.zip)

> OK, thanks. I've updated my code, since yours doesn't work on my system (it seems to only support eggs, but not other formats)

Can you elaborate on that?

The fact that `egg_info` is not always defined, and the basename being `EGG_INFO` are 2 separate problems.

In my case, for `pyHook`, `egg_info` is set to `c:\python27\lib\site-packages\pyhook-1.5.1-py2.7-win32.egg\EGG-INFO` and `dist.location` to `c:\python27\lib\site-packages\pyhook-1.5.1-py2.7-win32.egg`, so your code does not work.

```
>>> get_metadata('sphinx')
('c:\\users\\bjones\\downloads\\winpython-32bit-3.4.4.1qt5\\python-3.4.4\\lib\\site-packages\\Sphinx-1.3.5.dist-info', 'Sphinx-1.3.5-py3.4.egg-info')
```

The file should be kept in `Sphinx-1.3.5.dist-info` instead.

Hmmm. How can we tell these two cases apart? `if dist.egg_name() in dist.location:` then use `egg_name()`?

> The file should be kept in Sphinx-1.3.5.dist-info instead.

``` shell
> mkdir tmp
> cp -a ~/.local/lib/python2.7/site-packages/macholib-1.7.dist-info tmp/macholib-1.7.egg-info
> python2 -c 'from pkg_resources import find_distributions; print list(find_distributions("tmp"))'
[macholib 1.7 (/home/bpierre/progs/src/plover/tmp)]
```

Are you sure the extension is a hard requirement?

The question is more for you: can you demonstrate, in all cases, that renaming a distribution will not break pkg_resources' ability to find it? Since I'm not certain of the answer, I'd like to avoid doing so.

Since we're getting the original file name from `egg_info`, by the way of `pkg_resources`, I think it's pretty safe to assume that the version of `pkg_resources` that will be packaged in does in fact support it, no?

That's a good point. Let me do some testing here to make sure this works for me.

Here's my updated version -- how's this? [`__init__.zip`](https://github.com/pyinstaller/pyinstaller/files/180040/__init__.zip)

This version works fine. :+1: 

OK, I've pushed an update. Thanks for your help!

@benoit-pierre Coming back to this old issue: We urgently need a test-case for this, as the solution turned out to cause other problems. Please provide a list of directories and file-names for both of these packages as they are on your system and as they are to be in the frozen application. How did you install them?  Thanks.
I'm sorry, but I don't use pyinstaller anymore. Knowing now more about packages metadata, I can see that the current implementation can cause issues when a package was installed as an egg (egg-info metadata), and not from a wheel (dist-info metadata). With dist-info, the requirements and supported extras are listed in PKG-INFO, but with an egg, that information is in a dedicated `requires.txt` file.
I think this is better:
```diff
 PyInstaller/utils/hooks/__init__.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git i/PyInstaller/utils/hooks/__init__.py w/PyInstaller/utils/hooks/__init__.py
index 0f3230f2..df000eab 100755
--- i/PyInstaller/utils/hooks/__init__.py
+++ w/PyInstaller/utils/hooks/__init__.py
@@ -854,7 +854,11 @@ def copy_metadata(package_name):
     # Determine a destination directory based on the standardized egg name for
     # this distribution. This avoids some problems discussed in
     # https://github.com/pyinstaller/pyinstaller/issues/1888.
-    dest_dir = '{}.egg-info'.format(dist.egg_name())
+    if isinstance(dist, pkg_resources.DistInfoDistribution):
+        metadata_format = 'dist-info'
+    else:
+        metadata_format = 'egg-info'
+    dest_dir = '{}.{}'.format(dist.egg_name(), metadata_format)
     # Per https://github.com/pyinstaller/pyinstaller/issues/1888, ``egg_info``
     # isn't always defined. Try a workaround based on a suggestion by
     # @benoit-pierre in that issue.
```

Test:
```shell
> python -m venv venv
> ./venv/bin/pip freeze --all
pip==9.0.1
setuptools==28.8.0
> ./venv/bin/easy_install -q six
> ./venv/bin/pip install -q python-xlib
> ./venv/bin/python -c 'import sys; from PyInstaller.utils.hooks import copy_metadata; print(list
[
[('/home/bpierre/progs/src/pyinstaller/venv/lib/python3.6/site-packages/six-1.11.0-py3.6.egg/EGG-INFO',
 'six-1.11.0-py3.6.egg-info')],
[('/home/bpierre/progs/src/pyinstaller/venv/lib/python3.6/site-packages/python_xlib-0.20.dist-info',
 'python_xlib-0.20-py3.6.dist-info')]
]
```
Reading this issue form the top, the first comment describes two problems:

1. `dist.egg_info`might be None, but the egg-info exists.

   Inspecting the [code of pkg_resources 0.6.8](https://github.com/pypa/setuptools/blob/0.6.8/pkg_resources.py), released 1 Nov 2009,
   I could not find any relevant case where this might happen:
   - NullProvider is unusabel abyway, EmptyProvider returns nothing for all requests,
   - FileMetadata only knows "PKG-INFO"
   - PathMetadata is expected to be passed a non-None egg_info value.
   - EggProvider might have egg_info == None, if is is not egg at all. egg_info is set regardless if
      EGG-INFO actually exists.
   - EggMetadata is based on ZipProvider, which is based on EggProvider.

   Also the original poster did not show an example where this happens (apologies if I missed them, please point me to the example.).
   So I'm going to remove this part of the change in a later commit.

2. All Eggs' egg-info directory is called EGG-INFO, thus they are overwriting each other. This is the part which's solution is wrong and now brings the problems.

   pkg_resources.DistInfoDistribution was added in 0.6.28, released 22 Jul 2012, thus I assume it would okay to use it. Anyway, I'm not sure if we should hard-code these "metadata_format". This could make the code less future-proof. What about handling exactly the case which caused this error:
   ```
   dest_dir = metadata_dir[len(dist.location) + len(os.sep):] # taken from the old code
   if os.path.basename(dest_dir) == "EGG-INFO:
     # this e.g. is an zipped or unzipped egg
     dest_dir = '{}.egg-info'.format(dist.egg_name())
   ```

   Or maybe we could even use `dest_dir = os.path.basename(metadata_dir)`.
Thanks for you bug report. Since we did it wrong in #1888, we urgently should have a test-case for this. Please provide a list of directories and file-names for this package as it is installed on your system and as it has to be in the frozen application. Thanks.

For the records: Offending line: https://github.com/bjones1/pyinstaller/commit/d0fa0a65b6d3d35b47d941fd2e2797a564991aea#diff-cd6283d71452a367a7ae53d21b41140bR1331
Anyone have a workaround for this? I'm unable to add a hidden import of the form `package[extraname]` and unsure of how to build a hook to achieve what I need, plzhalp
@mattjreynolds Hi! Have you found a workaround for this by any chance? :slightly_smiling_face: We are running into the same issue.
@efiop No, sorry, I've not looked at this code in the last year, but from a quick glance back I think all I had to do for my use case was add a couple of hooks to handle the fact that the google package names don't match up properly.
@mattjreynolds Oops, it looks like I've accidentally linked you instead of @mattspring . I'm really sorry about that. Thanks for the tip though! :slightly_smiling_face: 
Hi @htgoebel.
We believe that we have similar issue to one described by @ulope.
In our build, we rely on google-resumable-media, which till version `0.3.2`
used `pkg_resources.require('requests >= 2.18.0')` to ensure that `requests` are
activated.

In version 0.3.3 they started to use `pkg_resources.require('google-resumable-media[requests]')`
to activate required package.

Now, upon building we get `pkg_resources.UnknownExtra: google-resumable-media 0.3.3 has no such extra feature 'requests'`.

We tried writing hook that would copy metadata of `requests` but it still fails to build.

Do you, by any chance now some workaround for this issue?

And if not, could you help us pinpoint some requirements that would need to be fulfilled
in order to create proper PR?
@efiop I wasn't actually able to find a workaround for this issue. I ended up refactoring my package to not use the extras dependency since it wasn't deeply tied to my application -- that definitely won't be feasible for everyone. Sorry 🤷‍♂ 
@mattspring Thanks for the info! :slightly_smiling_face: As @pared noted, it is  a dependency of a dependency that is causing this issue for us, so it would be ideal to fix this in PyInstaller. I'm also happy to help in fixing this, @htgoebel it would be great if you could provide some directions.
Just FYI: we use [this hook](https://github.com/raiden-network/raiden/blob/2dc84ad06177fd73a5f561486f3b4bcf5322d648/tools/pyinstaller_hooks/hook-raiden.py#L11-L27) as a workaround
@ulope Would be creat i you could provide a pull-reqeust for a generic solution.

Resolving patch:
diff --git a/PyInstaller/hooks/hook-gevent.py b/PyInstaller/hooks/hook-gevent.py
--- a/PyInstaller/hooks/hook-gevent.py
+++ b/PyInstaller/hooks/hook-gevent.py
@@ -9,7 +9,8 @@
 # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
 #-----------------------------------------------------------------------------
 
-from PyInstaller.utils.hooks import collect_all
+from PyInstaller.utils.hooks import collect_all, copy_metadata
+from PyInstaller.compat import is_win
 
 excludedimports = ["gevent.testing", "gevent.tests"]
 
@@ -19,3 +20,12 @@
         "gevent.testing" not in name or "gevent.tests" not in name),
     include_py_files=False,
     exclude_datas=["**/tests"])
+
+datas += copy_metadata("zope.interface")
+datas += copy_metadata("greenlet")
+datas += copy_metadata("zope.event")
+datas += copy_metadata("setuptools")
+
+if is_win:
+    datas += copy_metadata("cffi")
+    datas += copy_metadata("pycparser")
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
@@ -894,7 +894,9 @@ def collect_system_data_files(path, destdir=None, include_py_files=False):
 
 
 def copy_metadata(package_name):
-    """
+    """Collect distribution metadata so that
+    ``pkg_resources.get_distribution()`` can find it.
+
     This function returns a list to be assigned to the ``datas`` global
     variable. This list instructs PyInstaller to copy the metadata for the
     given package to PyInstaller's data directory.
@@ -905,61 +907,96 @@ def copy_metadata(package_name):
         Specifies the name of the package for which metadata should be copied.
 
     Returns
-    ----------
+    -------
     list
         This should be assigned to ``datas``.
 
     Examples
-    ----------
+    --------
         >>> from PyInstaller.utils.hooks import copy_metadata
         >>> copy_metadata('sphinx')
         [('c:\\python27\\lib\\site-packages\\Sphinx-1.3.2.dist-info',
           'Sphinx-1.3.2.dist-info')]
-    """
 
-    # Some notes: to look at the metadata locations for all installed
-    # packages::
-    #
-    #     for key, value in pkg_resources.working_set.by_key.iteritems():
-    #         print('{}: {}'.format(key, value.egg_info))
-    #
-    # Looking at this output, I see three general types of packages:
-    #
-    # 1. ``pypubsub: c:\python27\lib\site-packages\pypubsub-3.3.0-py2.7.egg\EGG-INFO``
-    # 2. ``codechat: c:\users\bjones\documents\documentation\CodeChat.egg-info``
-    # 3. ``zest.releaser: c:\python27\lib\site-packages\zest.releaser-6.2.dist-info``
-    # 4. ``pyserial: None``
-    #
-    # The first item shows that some metadata will be nested inside an egg. I
-    # assume we'll have to deal with zipped eggs, but I don't have any examples
-    # handy. The second and third items show different naming conventions for
-    # the metadata-containing directory. The fourth item shows a package with no
-    # metadata.
-    #
-    # So, in cases 1-3, copy the metadata directory. In case 4, emit an error
-    # -- there's no metadata to copy.
-    # See https://pythonhosted.org/setuptools/pkg_resources.html#getting-or-creating-distributions.
-    # Unfortunately, there's no documentation on the ``egg_info`` attribute; it
-    # was found through trial and error.
+
+    Some packages rely on metadata files accessed through the
+    ``pkg_resources`` module. Normally |PyInstaller| does not include these
+    metadata files. If a package fails without them, you can use this
+    function in a hook file to easily add them to the bundle. The tuples in
+    the returned list have two strings. The first is the full pathname to a
+    folder in this system. The second is the folder name only. When these
+    tuples are added to ``datas``\\ , the folder will be bundled at the top
+    level.
+
+    .. versionchanged:: 4.3.1
+
+        Prevent ``dist-info`` metadata folders being renamed to ``egg-info``
+        which broke ``pkg_resources.require`` with *extras* (see
+        :issue:`#3033`).
+
+    """
     dist = pkg_resources.get_distribution(package_name)
-    metadata_dir = dist.egg_info
-    # Determine a destination directory based on the standardized egg name for
-    # this distribution. This avoids some problems discussed in
-    # https://github.com/pyinstaller/pyinstaller/issues/1888.
-    dest_dir = '{}.egg-info'.format(dist.egg_name())
-    # Per https://github.com/pyinstaller/pyinstaller/issues/1888, ``egg_info``
-    # isn't always defined. Try a workaround based on a suggestion by
-    # @benoit-pierre in that issue.
-    if metadata_dir is None:
-        # We assume that this is an egg, so guess a name based on `egg_name()
-        # <https://pythonhosted.org/setuptools/pkg_resources.html#distribution-methods>`_.
-        metadata_dir = os.path.join(dist.location, dest_dir)
-
-    assert os.path.exists(metadata_dir)
-    logger.debug('Package {} metadata found in {} belongs in {}'.format(
-      package_name, metadata_dir, dest_dir))
-
-    return [(metadata_dir, dest_dir)]
+    return [(dist.egg_info,
+             _copy_metadata_dest(dist.egg_info, dist.project_name))]
+
+
+def _normalise_dist(name: str) -> str:
+    return name.lower().replace("_", "-")
+
+
+def _copy_metadata_dest(egg_path: str, project_name: str) -> str:
+    """Choose an appropriate destination path for a distribution's metadata.
+
+    Args:
+        egg_path:
+            The output of ``pkg_resources.get_distribution("xyz").egg_info``:
+            A full path to the source ``xyz-version.dist-info`` or
+            ``xyz-version.egg-info`` folder containing package metadata.
+        project_name:
+            The distribution name given
+    Returns:
+        The *dest* parameter: where in the bundle should this folder go.
+    Raises:
+        RuntimeError:
+            If **egg_path** is none. i.e. No metadata found.
+
+    """
+    if egg_path is None:
+        # According to older implementations of this function, packages may
+        # have no metadata. I have no idea when this can happen...
+        raise RuntimeError(
+            f"No metadata path found for distribution '{project_name}'.")
+
+    egg_path = Path(egg_path)
+    _project_name = _normalise_dist(project_name)
+
+    # There has been a fair amount of whack-a-mole fixing to this step.
+    # If new cases appear which this function can't handle, add them to the
+    # corresponding test:
+    #   tests/unit/test_hookutils.py::test_copy_metadata_dest()
+    # See there also for example input/outputs.
+
+    # The most obvious answer is that the metadata folder should have the same
+    # name in a PyInstaller build as it does normally::
+    if _normalise_dist(egg_path.name).startswith(_project_name):
+        # e.g. .../lib/site-packages/xyz-1.2.3.dist-info
+        return egg_path.name
+
+    # Using just the base-name breaks for an egg_path of the form:
+    #   '.../site-packages/xyz-version.win32.egg/EGG-INFO'
+    # because multiple collected metadata folders will be written to the same
+    # name 'EGG-INFO' and clobber each other (see #1888).
+    # In this case, the correct behaviour appears to be to use the last 2 parts
+    # of the path:
+    if len(egg_path.parts) >= 2:
+        if _normalise_dist(egg_path.parts[-2]).startswith(_project_name):
+            return os.path.join(*egg_path.parts[-2:])
+
+    # This is something unheard of.
+    raise RuntimeError(
+        f"Unknown metadata type '{egg_path}' from the '{project_name}' "
+        f"distribution. Please report this at "
+        f"https://github/pyinstaller/pyinstaller/issues.")
 
 
 def get_installer(module):


Test patch:
diff --git a/tests/unit/test_hookutils.py b/tests/unit/test_hookutils.py
--- a/tests/unit/test_hookutils.py
+++ b/tests/unit/test_hookutils.py
@@ -12,13 +12,14 @@
 
 import os
 import pytest
+import pathlib
 import shutil
 from os.path import join
 
 from PyInstaller.utils.hooks import collect_data_files, collect_submodules, \
     get_module_file_attribute, remove_prefix, remove_suffix, \
     remove_file_extension, is_module_or_submodule, \
-    is_module_satisfies
+    is_module_satisfies, _copy_metadata_dest
 from PyInstaller.compat import exec_python, ALL_SUFFIXES, is_win
 
 
@@ -322,3 +323,35 @@ def test_collect_data_all_included(data_lists):
 def test_get_module_file_attribute_non_exist_module():
     with pytest.raises(ImportError):
         get_module_file_attribute('pyinst_nonexisting_module_name')
+
+
+@pytest.mark.parametrize("egg_path,name,target", [
+    # Something installed via `pip install -e .`.
+    ("editable/install/CodeChat.egg-info", "CodeChat", "CodeChat.egg-info"),
+    # An egg distribution - it's unlikely we'll ever see these now.
+    ("lib/site-packages/pypubsub-3.3.0-py2.7.egg/EGG-INFO",
+     "pypubsub", "pypubsub-3.3.0-py2.7.egg/EGG-INFO"),
+    # A classic wheel-installed distribution.
+    ("lib/site-packages/zest.releaser-6.2.dist-info",
+     "zest.releaser", "zest.releaser-6.2.dist-info"),
+    # Must be tolerant to case and -/_ mismatch.
+    ("/site-packages/importlib_metadata-4.0.1.dist-info",
+     "ImPorTlib-mEtADatA", "importlib_metadata-4.0.1.dist-info")
+])
+def test_copy_metadata_dest(egg_path, name, target):
+    """Test choosing dest path for copy_metadata() across distribution types.
+    """
+    # Convert posix style filenames to native paths. i.e. replace '/' with '\'
+    # on Windows.
+    egg_path = str(pathlib.PurePath(egg_path))
+    target = str(pathlib.PurePath(target))
+
+    assert _copy_metadata_dest(egg_path, name) == target
+
+
+def test_erroneous_distribution_type():
+    with pytest.raises(RuntimeError, match="Unknown .* type 'foo' from the "
+                                           "'bar' distribution"):
+        _copy_metadata_dest("foo", "bar")
+    with pytest.raises(RuntimeError, match=r"No .* distribution 'foo'\."):
+        _copy_metadata_dest(None, "foo")