Repository: pypa/setuptools

Issue:
[FR] Auto-include license files in source distributions
### What's the problem this feature will solve?

License files are only included in the source distribution if they are specified in either `license_file`, `license_files` or `MANIFEST.in`. `wheel` automatically adds a few common glob patterns if the `license_files` option isn't specified. https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file

The result is a situation where a license file is included in the wheel, but not in the source distribution.

### Describe the solution you'd like

Match the behavior of `wheel` and include the most common glob patterns automatically if neither `license_file` nor `license_files` is specified.

### Alternative Solutions

 --

### Additional context

PR that originally added support for `license_files`: https://github.com/pypa/setuptools/pull/1767
Commit that added glob patterns in `wheel`: https://github.com/pypa/wheel/commit/59976ab294e1b118f42cab404d95df66ed55f7e4


### Code of Conduct

- [X] I agree to follow the PSF Code of Conduct


Discussion:
Thanks for the description. It sounds reasonable.

I'm a little reluctant to add to the list of implicitly included files. In my experience, there's an endless long tail of files that a user might expect to be included implicitly, and wherever we draw the line, that's where users will get confused. I've been leaning toward not including any files implicitly (only those that are explicitly implicated, such as those indicated by 'license files').

On the other hand, I'd like users to be able to distribute projects with minimal boilerplate, so having some reasonable defaults seems acceptable. I'd accept a PR for this feature as long as it isn't complicated.

Resolving patch:
diff --git a/setuptools/command/sdist.py b/setuptools/command/sdist.py
--- a/setuptools/command/sdist.py
+++ b/setuptools/command/sdist.py
@@ -4,6 +4,7 @@
 import sys
 import io
 import contextlib
+from glob import iglob
 
 from setuptools.extern import ordered_set
 
@@ -194,29 +195,41 @@ def check_license(self):
         """Checks if license_file' or 'license_files' is configured and adds any
         valid paths to 'self.filelist'.
         """
-
-        files = ordered_set.OrderedSet()
-
         opts = self.distribution.get_option_dict('metadata')
 
-        # ignore the source of the value
-        _, license_file = opts.get('license_file', (None, None))
-
-        if license_file is None:
-            log.debug("'license_file' option was not specified")
-        else:
-            files.add(license_file)
-
+        files = ordered_set.OrderedSet()
         try:
-            files.update(self.distribution.metadata.license_files)
+            license_files = self.distribution.metadata.license_files
         except TypeError:
             log.warn("warning: 'license_files' option is malformed")
-
-        for f in files:
-            if not os.path.exists(f):
-                log.warn(
-                    "warning: Failed to find the configured license file '%s'",
-                    f)
-                files.remove(f)
-
-        self.filelist.extend(files)
+            license_files = ordered_set.OrderedSet()
+        patterns = license_files if isinstance(license_files, ordered_set.OrderedSet) \
+            else ordered_set.OrderedSet(license_files)
+
+        if 'license_file' in opts:
+            log.warn(
+                "warning: the 'license_file' option is deprecated, "
+                "use 'license_files' instead")
+            patterns.append(opts['license_file'][1])
+
+        if 'license_file' not in opts and 'license_files' not in opts:
+            # Default patterns match the ones wheel uses
+            # See https://wheel.readthedocs.io/en/stable/user_guide.html
+            # -> 'Including license files in the generated wheel file'
+            patterns = ('LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*')
+
+        for pattern in patterns:
+            for path in iglob(pattern):
+                if path.endswith('~'):
+                    log.debug(
+                        "ignoring license file '%s' as it looks like a backup",
+                        path)
+                    continue
+
+                if path not in files and os.path.isfile(path):
+                    log.info(
+                        "adding license file '%s' (matched pattern '%s')",
+                        path, pattern)
+                    files.add(path)
+
+        self.filelist.extend(sorted(files))


Test patch:
diff --git a/setuptools/tests/test_egg_info.py b/setuptools/tests/test_egg_info.py
--- a/setuptools/tests/test_egg_info.py
+++ b/setuptools/tests/test_egg_info.py
@@ -537,7 +537,7 @@ def test_doesnt_provides_extra(self, tmpdir_cwd, env):
             'setup.cfg': DALS("""
                               """),
             'LICENSE': "Test license"
-        }, False),  # no license_file attribute
+        }, True),  # no license_file attribute, LICENSE auto-included
         ({
             'setup.cfg': DALS("""
                               [metadata]
@@ -545,7 +545,15 @@ def test_doesnt_provides_extra(self, tmpdir_cwd, env):
                               """),
             'MANIFEST.in': "exclude LICENSE",
             'LICENSE': "Test license"
-        }, False)  # license file is manually excluded
+        }, False),  # license file is manually excluded
+        pytest.param({
+            'setup.cfg': DALS("""
+                              [metadata]
+                              license_file = LICEN[CS]E*
+                              """),
+            'LICENSE': "Test license",
+            }, True,
+            id="glob_pattern"),
     ])
     def test_setup_cfg_license_file(
             self, tmpdir_cwd, env, files, license_in_sources):
@@ -625,7 +633,7 @@ def test_setup_cfg_license_file(
             'setup.cfg': DALS("""
                               """),
             'LICENSE': "Test license"
-        }, [], ['LICENSE']),  # no license_files attribute
+        }, ['LICENSE'], []),  # no license_files attribute, LICENSE auto-included
         ({
             'setup.cfg': DALS("""
                               [metadata]
@@ -644,7 +652,36 @@ def test_setup_cfg_license_file(
             'MANIFEST.in': "exclude LICENSE-XYZ",
             'LICENSE-ABC': "ABC license",
             'LICENSE-XYZ': "XYZ license"
-        }, ['LICENSE-ABC'], ['LICENSE-XYZ'])  # subset is manually excluded
+        }, ['LICENSE-ABC'], ['LICENSE-XYZ']),  # subset is manually excluded
+        pytest.param({
+            'setup.cfg': "",
+            'LICENSE-ABC': "ABC license",
+            'COPYING-ABC': "ABC copying",
+            'NOTICE-ABC': "ABC notice",
+            'AUTHORS-ABC': "ABC authors",
+            'LICENCE-XYZ': "XYZ license",
+            'LICENSE': "License",
+            'INVALID-LICENSE': "Invalid license",
+            }, [
+            'LICENSE-ABC',
+            'COPYING-ABC',
+            'NOTICE-ABC',
+            'AUTHORS-ABC',
+            'LICENCE-XYZ',
+            'LICENSE',
+            ], ['INVALID-LICENSE'],
+            # ('LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*')
+            id="default_glob_patterns"),
+        pytest.param({
+            'setup.cfg': DALS("""
+                              [metadata]
+                              license_files =
+                                  LICENSE*
+                              """),
+            'LICENSE-ABC': "ABC license",
+            'NOTICE-XYZ': "XYZ notice",
+            }, ['LICENSE-ABC'], ['NOTICE-XYZ'],
+            id="no_default_glob_patterns"),
     ])
     def test_setup_cfg_license_files(
             self, tmpdir_cwd, env, files, incl_licenses, excl_licenses):
@@ -749,7 +786,28 @@ def test_setup_cfg_license_files(
             'LICENSE-PQR': "PQR license",
             'LICENSE-XYZ': "XYZ license"
             # manually excluded
-        }, ['LICENSE-XYZ'], ['LICENSE-ABC', 'LICENSE-PQR'])
+        }, ['LICENSE-XYZ'], ['LICENSE-ABC', 'LICENSE-PQR']),
+        pytest.param({
+            'setup.cfg': DALS("""
+                              [metadata]
+                              license_file = LICENSE*
+                              """),
+            'LICENSE-ABC': "ABC license",
+            'NOTICE-XYZ': "XYZ notice",
+            }, ['LICENSE-ABC'], ['NOTICE-XYZ'],
+            id="no_default_glob_patterns"),
+        pytest.param({
+            'setup.cfg': DALS("""
+                              [metadata]
+                              license_file = LICENSE*
+                              license_files =
+                                NOTICE*
+                              """),
+            'LICENSE-ABC': "ABC license",
+            'NOTICE-ABC': "ABC notice",
+            'AUTHORS-ABC': "ABC authors",
+            }, ['LICENSE-ABC', 'NOTICE-ABC'], ['AUTHORS-ABC'],
+            id="combined_glob_patterrns"),
     ])
     def test_setup_cfg_license_file_license_files(
             self, tmpdir_cwd, env, files, incl_licenses, excl_licenses):
diff --git a/setuptools/tests/test_manifest.py b/setuptools/tests/test_manifest.py
--- a/setuptools/tests/test_manifest.py
+++ b/setuptools/tests/test_manifest.py
@@ -55,6 +55,7 @@ def touch(filename):
 default_files = frozenset(map(make_local_path, [
     'README.rst',
     'MANIFEST.in',
+    'LICENSE',
     'setup.py',
     'app.egg-info/PKG-INFO',
     'app.egg-info/SOURCES.txt',