From 20407bf3afdff569a16262d72b48df82e35f012d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 17 Apr 2021 20:48:12 +0200 Subject: [PATCH] Add License-File field to package metadata --- changelog.d/2645.change.rst | 4 +++ setuptools/command/egg_info.py | 9 +++++- setuptools/command/sdist.py | 46 ------------------------------- setuptools/config.py | 5 ++++ setuptools/dist.py | 37 +++++++++++++++++++++++-- setuptools/tests/test_egg_info.py | 29 +++++++++++++++++++ setuptools/tests/test_manifest.py | 1 - 7 files changed, 81 insertions(+), 50 deletions(-) create mode 100644 changelog.d/2645.change.rst diff --git a/changelog.d/2645.change.rst b/changelog.d/2645.change.rst new file mode 100644 index 00000000000..b22385c1b74 --- /dev/null +++ b/changelog.d/2645.change.rst @@ -0,0 +1,4 @@ +Added ``License-File`` (multiple) to the output package metadata. +The field will contain the path of a license file, matched by the +``license_file`` (deprecated) and ``license_files`` options, +relative to ``.dist-info``. - by :user:`cdce8p` diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py index 1f120b67d18..26ff9a4cd06 100644 --- a/setuptools/command/egg_info.py +++ b/setuptools/command/egg_info.py @@ -539,6 +539,7 @@ def run(self): if not os.path.exists(self.manifest): self.write_manifest() # it must exist so it'll get in the list self.add_defaults() + self.add_license_files() if os.path.exists(self.template): self.read_template() self.prune_file_list() @@ -575,7 +576,6 @@ def _should_suppress_warning(msg): def add_defaults(self): sdist.add_defaults(self) - self.check_license() self.filelist.append(self.template) self.filelist.append(self.manifest) rcfiles = list(walk_revctrl()) @@ -592,6 +592,13 @@ def add_defaults(self): ei_cmd = self.get_finalized_command('egg_info') self.filelist.graft(ei_cmd.egg_info) + def add_license_files(self): + license_files = self.distribution.metadata.license_files_computed + for lf in license_files: + log.info("adding license file '%s'", lf) + pass + self.filelist.extend(license_files) + def prune_file_list(self): build = self.get_finalized_command('build') base_dir = self.distribution.get_fullname() diff --git a/setuptools/command/sdist.py b/setuptools/command/sdist.py index a6ea814a30b..4a014283c86 100644 --- a/setuptools/command/sdist.py +++ b/setuptools/command/sdist.py @@ -4,9 +4,6 @@ import sys import io import contextlib -from glob import iglob - -from setuptools.extern import ordered_set from .py36compat import sdist_add_defaults @@ -190,46 +187,3 @@ def read_manifest(self): continue self.filelist.append(line) manifest.close() - - def check_license(self): - """Checks if license_file' or 'license_files' is configured and adds any - valid paths to 'self.filelist'. - """ - opts = self.distribution.get_option_dict('metadata') - - files = ordered_set.OrderedSet() - try: - license_files = self.distribution.metadata.license_files - except TypeError: - log.warn("warning: 'license_files' option is malformed") - 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)) diff --git a/setuptools/config.py b/setuptools/config.py index 4a6cd4694b0..44de7cf5f23 100644 --- a/setuptools/config.py +++ b/setuptools/config.py @@ -520,6 +520,11 @@ def parsers(self): 'obsoletes': parse_list, 'classifiers': self._get_parser_compound(parse_file, parse_list), 'license': exclude_files_parser('license'), + 'license_file': self._deprecated_config_handler( + exclude_files_parser('license_file'), + "The license_file parameter is deprecated, " + "use license_files instead.", + DeprecationWarning), 'license_files': parse_list, 'description': parse_file, 'long_description': parse_file, diff --git a/setuptools/dist.py b/setuptools/dist.py index c7af35dc99f..de2367e6dd3 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -15,9 +15,10 @@ from distutils.util import strtobool from distutils.debug import DEBUG from distutils.fancy_getopt import translate_longopt +from glob import iglob import itertools import textwrap -from typing import List, Optional, TYPE_CHECKING +from typing import List, Optional, Set, TYPE_CHECKING from collections import defaultdict from email import message_from_file @@ -146,6 +147,8 @@ def read_pkg_file(self, file): self.provides = None self.obsoletes = None + self.license_files_computed = _read_list_from_msg(msg, 'license-file') + def single_line(val): # quick and dirty validation for description pypa/setuptools#1390 @@ -228,6 +231,8 @@ def write_field(key, value): for extra in self.provides_extras: write_field('Provides-Extra', extra) + self._write_list(file, 'License-File', self.license_files_computed) + sequence = tuple, list @@ -427,7 +432,9 @@ class Distribution(_Distribution): 'long_description_content_type': None, 'project_urls': dict, 'provides_extras': ordered_set.OrderedSet, - 'license_files': ordered_set.OrderedSet, + 'license_file': None, + 'license_files': None, + 'license_files_computed': list, } _patched_dist = None @@ -586,6 +593,31 @@ def _clean_req(self, req): req.marker = None return req + def _finalize_license_files(self): + """Compute names of all license files which should be included.""" + files = set() + license_files: Optional[List[str]] = self.metadata.license_files + patterns: Set[str] = set(license_files) if license_files else set() + + license_file: Optional[str] = self.metadata.license_file + if license_file: + patterns.add(license_file) + + if license_files is None and license_file is None: + # 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('~'): + continue + if path not in files and os.path.isfile(path): + files.add(path) + + self.metadata.license_files_computed = sorted(files) + # FIXME: 'Distribution._parse_config_files' is too complex (14) def _parse_config_files(self, filenames=None): # noqa: C901 """ @@ -742,6 +774,7 @@ def parse_config_files(self, filenames=None, ignore_option_errors=False): parse_configuration(self, self.command_options, ignore_option_errors=ignore_option_errors) self._finalize_requires() + self._finalize_license_files() def fetch_build_eggs(self, requires): """Resolve pre-setup requirements""" diff --git a/setuptools/tests/test_egg_info.py b/setuptools/tests/test_egg_info.py index 80d35774249..0dd700ee792 100644 --- a/setuptools/tests/test_egg_info.py +++ b/setuptools/tests/test_egg_info.py @@ -829,6 +829,35 @@ def test_setup_cfg_license_file_license_files( for lf in excl_licenses: assert sources_lines.count(lf) == 0 + def test_license_file_attr_pkg_info(self, tmpdir_cwd, env): + """All matched license files should have a corresponding License-File.""" + self._create_project() + path.build({ + "setup.cfg": DALS(""" + [metadata] + license_files = + LICENSE* + """), + "LICENSE-ABC": "ABC license", + "LICENSE-XYZ": "XYZ license", + "NOTICE": "not included", + }) + + environment.run_setup_py( + cmd=['egg_info'], + pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]) + ) + egg_info_dir = os.path.join('.', 'foo.egg-info') + with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file: + pkg_info_lines = pkginfo_file.read().split('\n') + license_file_lines = [ + line for line in pkg_info_lines if line.startswith('License-File:')] + + # Only 'LICENSE-ABC' and 'LICENSE-XYZ' should have been matched + assert len(license_file_lines) == 2 + assert "License-File: LICENSE-ABC" in license_file_lines + assert "License-File: LICENSE-XYZ" in license_file_lines + def test_long_description_content_type(self, tmpdir_cwd, env): # Test that specifying a `long_description_content_type` keyword arg to # the `setup` function results in writing a `Description-Content-Type` diff --git a/setuptools/tests/test_manifest.py b/setuptools/tests/test_manifest.py index 589cefb2c01..82bdb9c6432 100644 --- a/setuptools/tests/test_manifest.py +++ b/setuptools/tests/test_manifest.py @@ -55,7 +55,6 @@ 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',