diff --git a/doc/pyproject_toml.rst b/doc/pyproject_toml.rst index 9d4f9da1..f8ef6613 100644 --- a/doc/pyproject_toml.rst +++ b/doc/pyproject_toml.rst @@ -98,6 +98,8 @@ requires-python license A table with either a ``file`` key (a relative path to a license file) or a ``text`` key (the license text). +license-files + A list of glob patterns for license files to include. Defaults to ``['COPYING*', 'LICENSE*']``. authors A list of tables with ``name`` and ``email`` keys (both optional) describing the authors of the project. diff --git a/flit_core/flit_core/common.py b/flit_core/flit_core/common.py index 5385c9c0..719e1072 100644 --- a/flit_core/flit_core/common.py +++ b/flit_core/flit_core/common.py @@ -354,6 +354,7 @@ class Metadata(object): obsoletes_dist = () requires_external = () provides_extra = () + license_files = () dynamic = () metadata_version = "2.3" @@ -425,6 +426,10 @@ def write_metadata_file(self, fp): for clsfr in self.classifiers: fp.write(u'Classifier: {}\n'.format(clsfr)) + # TODO: License-File requires Metadata-Version '2.4' + # for file in self.license_files: + # fp.write(u'License-File: {}\n'.format(file)) + for req in self.requires_dist: normalised_req = self._normalise_requires_dist(req) fp.write(u'Requires-Dist: {}\n'.format(normalised_req)) diff --git a/flit_core/flit_core/config.py b/flit_core/flit_core/config.py index 25ad55d0..adf3b3a3 100644 --- a/flit_core/flit_core/config.py +++ b/flit_core/flit_core/config.py @@ -60,6 +60,7 @@ class ConfigError(ValueError): 'readme', 'requires-python', 'license', + 'license-files', 'authors', 'maintainers', 'keywords', @@ -73,6 +74,8 @@ class ConfigError(ValueError): 'dynamic', } +default_license_files_globs = ['COPYING*', 'LICENSE*'] + def read_flit_config(path): """Read and check the `pyproject.toml` file with data about the package. @@ -427,6 +430,13 @@ def _prep_metadata(md_sect, path): # For internal use, record the main requirements as a '.none' extra. res.reqs_by_extra['.none'] = reqs_noextra + if path: + license_files = _license_files_from_globs( + path.parent, default_license_files_globs, use_defaults=True + ) + res.referenced_files.extend(license_files) + md_dict['license_files'] = license_files + return res def _expand_requires_extra(re): @@ -439,6 +449,41 @@ def _expand_requires_extra(re): yield '{} ; extra == "{}"'.format(req, extra) +def _license_files_from_globs(project_dir: Path, globs: list[str], use_defaults: bool = False) -> list[str]: + license_files: list[Path] = [] + for pattern in globs: + if pattern.startswith("/"): + raise ConfigError( + "Invalid glob pattern for [project.license-files]: '{}'. " + "Pattern must not start with '/'.".format(pattern) + ) + if ".." in pattern: + raise ConfigError( + "Invalid glob pattern for [project.license-files]: '{}'. " + "Pattern must not contain '..'".format(pattern) + ) + try: + files = list( + filter( + lambda file: file.is_file(), + map( + lambda file: file.relative_to(project_dir), + project_dir.glob(pattern) + ) + ) + ) + except ValueError as ex: + raise ConfigError( + "Invalid glob pattern for [project.license-files]: '{}'. {}".format(pattern, ex.args[0]) + ) + + if not files and not use_defaults: + raise ConfigError( + "No files found for [project.license-files]: '{}' pattern".format(pattern) + ) + license_files.extend(files) + return sorted(map(str, license_files)) + def _check_type(d, field_name, cls): if not isinstance(d[field_name], cls): raise ConfigError( @@ -551,6 +596,18 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: "file or text field required in [project.license] table" ) + license_files = [] + if 'license-files' in proj: + _check_type(proj, 'license-files', list) + globs = proj['license-files'] + license_files = _license_files_from_globs(path.parent, globs) + else: + license_files = _license_files_from_globs( + path.parent, default_license_files_globs, use_defaults=True + ) + lc.referenced_files.extend(license_files) + md_dict['license_files'] = license_files + if 'authors' in proj: _check_type(proj, 'authors', list) md_dict.update(pep621_people(proj['authors'])) diff --git a/flit_core/flit_core/wheel.py b/flit_core/flit_core/wheel.py index 66b0ac29..1a4a30c9 100644 --- a/flit_core/flit_core/wheel.py +++ b/flit_core/flit_core/wheel.py @@ -183,10 +183,8 @@ def write_metadata(self): with self._write_to_zip(self.dist_info + '/entry_points.txt') as f: common.write_entry_points(self.entrypoints, f) - for base in ('COPYING', 'LICENSE'): - for path in sorted(self.directory.glob(base + '*')): - if path.is_file(): - self._add_file(path, '%s/%s' % (self.dist_info, path.name)) + for file in self.metadata.license_files: + self._add_file(self.directory / file, '%s/licenses/%s' % (self.dist_info, file)) with self._write_to_zip(self.dist_info + '/WHEEL') as f: _write_wheel_file(f, supports_py2=self.metadata.supports_py2) diff --git a/flit_core/pyproject.toml b/flit_core/pyproject.toml index a027ff0c..5c12d6f2 100644 --- a/flit_core/pyproject.toml +++ b/flit_core/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [] requires-python = '>=3.6' readme = "README.rst" license = {file = "LICENSE"} +license-files = ["LICENSE*", "flit_core/vendor/**/LICENSE*"] classifiers = [ "License :: OSI Approved :: BSD License", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/flit_core/tests_core/samples/pep621_license_files/LICENSE b/flit_core/tests_core/samples/pep621_license_files/LICENSE new file mode 100644 index 00000000..7f5c1948 --- /dev/null +++ b/flit_core/tests_core/samples/pep621_license_files/LICENSE @@ -0,0 +1 @@ +This file should be added to wheels diff --git a/flit_core/tests_core/samples/pep621_license_files/README.rst b/flit_core/tests_core/samples/pep621_license_files/README.rst new file mode 100644 index 00000000..304360ca --- /dev/null +++ b/flit_core/tests_core/samples/pep621_license_files/README.rst @@ -0,0 +1 @@ +Readme diff --git a/flit_core/tests_core/samples/pep621_license_files/module/vendor/LICENSE_VENDOR b/flit_core/tests_core/samples/pep621_license_files/module/vendor/LICENSE_VENDOR new file mode 100644 index 00000000..7f5c1948 --- /dev/null +++ b/flit_core/tests_core/samples/pep621_license_files/module/vendor/LICENSE_VENDOR @@ -0,0 +1 @@ +This file should be added to wheels diff --git a/flit_core/tests_core/samples/pep621_license_files/module1a.py b/flit_core/tests_core/samples/pep621_license_files/module1a.py new file mode 100644 index 00000000..87f0370d --- /dev/null +++ b/flit_core/tests_core/samples/pep621_license_files/module1a.py @@ -0,0 +1,3 @@ +"""Example module""" + +__version__ = '0.1' diff --git a/flit_core/tests_core/samples/pep621_license_files/pyproject.toml b/flit_core/tests_core/samples/pep621_license_files/pyproject.toml new file mode 100644 index 00000000..1882615a --- /dev/null +++ b/flit_core/tests_core/samples/pep621_license_files/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "module1" +authors = [ + {name = "Sir Röbin", email = "robin@camelot.uk"} +] +maintainers = [ + {name = "Sir Galahad"} +] +readme = "README.rst" +license-files = ["**/LICENSE*"] +requires-python = ">=3.7" +dependencies = [ + "requests >= 2.18", + "docutils", +] +keywords = ["example", "test"] +dynamic = [ + "version", + "description", +] + +[project.optional-dependencies] +test = [ + "pytest", + "mock; python_version<'3.6'" +] + +[project.urls] +homepage = "http://github.com/sirrobin/module1" + +[project.entry-points.flit_test_example] +foo = "module1:main" + +[tool.flit.module] +name = "module1a" diff --git a/flit_core/tests_core/test_config.py b/flit_core/tests_core/test_config.py index 7d9e2c86..4586db5e 100644 --- a/flit_core/tests_core/test_config.py +++ b/flit_core/tests_core/test_config.py @@ -1,5 +1,8 @@ import logging +import re +import os from pathlib import Path +from unittest.mock import patch import pytest from flit_core import config @@ -178,3 +181,38 @@ def test_bad_pep621_readme(readme, err_match): } with pytest.raises(config.ConfigError, match=err_match): config.read_pep621_metadata(proj, samples_dir / 'pep621') + + +@pytest.mark.parametrize(('value', 'files'), [ + ('[]', []), + ('["LICENSE"]', ["LICENSE"]), + ('["LICENSE*"]', ["LICENSE"]), + ('["**/LICENSE*"]', ["LICENSE", "module/vendor/LICENSE_VENDOR"]), + ('["module/vendor/LICENSE*"]', ["module/vendor/LICENSE_VENDOR"]), + ('["LICENSE", "module/**/LICENSE*"]', ["LICENSE", "module/vendor/LICENSE_VENDOR"]), +]) +def test_pep621_license_files(value, files): + path = samples_dir / 'pep621_license_files' / 'pyproject.toml' + data = path.read_text() + data = re.sub(r"(^license-files = )(?:\[.*\])", r"\g<1>{}".format(value), data, 1, flags=re.M) + dir = os.getcwd() + try: + os.chdir(samples_dir / 'pep621_license_files') + with patch("pathlib.Path.read_text", return_value=data): + info = config.read_flit_config(path) + assert info.metadata['license_files'] == files + finally: + os.chdir(dir) + + +@pytest.mark.parametrize(('proj_bad', 'err_match'), [ + ({'license-files': ["/LICENSE"]}, "'/LICENSE'.+must not start with '/'"), + ({'license-files': ["../LICENSE"]}, "'../LICENSE'.+must not contain '..'"), + ({'license-files': ["**LICENSE"]}, "'\\*\\*LICENSE'.+Invalid pattern"), + ({'license-files': ["NOT_FOUND"]}, "No files found.+'NOT_FOUND'"), +]) +def test_bad_pep621_license_files(proj_bad, err_match): + proj = {'name': 'module1', 'version': '1.0', 'description': 'x'} + proj.update(proj_bad) + with pytest.raises(config.ConfigError, match=err_match): + config.read_pep621_metadata(proj, samples_dir / 'pep621') diff --git a/flit_core/tests_core/test_wheel.py b/flit_core/tests_core/test_wheel.py index 310f9c6c..b0a9f0a3 100644 --- a/flit_core/tests_core/test_wheel.py +++ b/flit_core/tests_core/test_wheel.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from zipfile import ZipFile @@ -45,3 +46,16 @@ def test_data_dir(tmp_path): assert_isfile(info.file) with ZipFile(info.file, 'r') as zf: assert 'module1-0.1.data/data/share/man/man1/foo.1' in zf.namelist() + + +def test_license_files(tmp_path): + dir = os.getcwd() + try: + os.chdir(samples_dir / 'pep621_license_files') + info = make_wheel_in(samples_dir / 'pep621_license_files' / 'pyproject.toml', tmp_path) + assert_isfile(info.file) + with ZipFile(info.file, 'r') as zf: + assert 'module1-0.1.dist-info/licenses/LICENSE' in zf.namelist() + assert 'module1-0.1.dist-info/licenses/module/vendor/LICENSE_VENDOR' in zf.namelist() + finally: + os.chdir(dir) diff --git a/tests/test_wheel.py b/tests/test_wheel.py index e39a2b0e..431c9b48 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -90,7 +90,7 @@ def test_wheel_src_module(copy_sample): with unpack(whl_file) as unpacked: assert_isfile(Path(unpacked, 'module3.py')) assert_isdir(Path(unpacked, 'module3-0.1.dist-info')) - assert_isfile(Path(unpacked, 'module3-0.1.dist-info', 'LICENSE')) + assert_isfile(Path(unpacked, 'module3-0.1.dist-info', 'licenses', 'LICENSE')) def test_editable_wheel_src_module(copy_sample): td = copy_sample('module3')