diff --git a/doc/pyproject_toml.rst b/doc/pyproject_toml.rst index 9d4f9da1..9620ce75 100644 --- a/doc/pyproject_toml.rst +++ b/doc/pyproject_toml.rst @@ -98,6 +98,9 @@ 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*', 'LICEN[CS]E*']``. 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..2b2af868 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,9 @@ class ConfigError(ValueError): 'dynamic', } +default_license_files_globs = ['COPYING*', 'LICEN[CS]E*'] +license_files_allowed_chars = re.compile(r'^[\w\-\.\/\*\?\[\]]+$') + def read_flit_config(path): """Read and check the `pyproject.toml` file with data about the package. @@ -427,6 +431,15 @@ 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 = sorted( + _license_files_from_globs( + path.parent, default_license_files_globs, warn_no_files=False + ) + ) + res.referenced_files.extend(license_files) + md_dict['license_files'] = license_files + return res def _expand_requires_extra(re): @@ -439,6 +452,43 @@ def _expand_requires_extra(re): yield '{} ; extra == "{}"'.format(req, extra) +def _license_files_from_globs(project_dir: Path, globs, warn_no_files = True): + license_files = set() + for pattern in globs: + if isabs_ish(pattern): + 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) + ) + if license_files_allowed_chars.match(pattern) is None: + raise ConfigError( + "Invalid glob pattern for [project.license-files]: '{}'. " + "Pattern contains invalid characters. " + "https://packaging.python.org/en/latest/specifications/pyproject-toml/#license-files" + ) + try: + files = [ + str(file.relative_to(project_dir)).replace(osp.sep, "/") + for file in project_dir.glob(pattern) + if file.is_file() + ] + except ValueError as ex: + raise ConfigError( + "Invalid glob pattern for [project.license-files]: '{}'. {}".format(pattern, ex.args[0]) + ) + + if not files and warn_no_files: + raise ConfigError( + "No files found for [project.license-files]: '{}' pattern".format(pattern) + ) + license_files.update(files) + return license_files + def _check_type(d, field_name, cls): if not isinstance(d[field_name], cls): raise ConfigError( @@ -525,6 +575,7 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: if 'requires-python' in proj: md_dict['requires_python'] = proj['requires-python'] + license_files = set() if 'license' in proj: _check_type(proj, 'license', dict) license_tbl = proj['license'] @@ -543,7 +594,7 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: raise ConfigError( "[project.license] should specify file or text, not both" ) - lc.referenced_files.append(license_tbl['file']) + license_files.add(license_tbl['file']) elif 'text' in license_tbl: pass else: @@ -551,6 +602,25 @@ def read_pep621_metadata(proj, path) -> LoadedConfig: "file or text field required in [project.license] table" ) + if 'license-files' in proj: + _check_type(proj, 'license-files', list) + globs = proj['license-files'] + license_files = _license_files_from_globs(path.parent, globs) + if isinstance(proj.get('license'), dict): + raise ConfigError( + "license-files cannot be used with a license table, " + "use 'project.license' with a license expression instead" + ) + else: + license_files.update( + _license_files_from_globs( + path.parent, default_license_files_globs, warn_no_files=False + ) + ) + license_files_sorted = sorted(license_files) + lc.referenced_files.extend(license_files_sorted) + md_dict['license_files'] = license_files_sorted + 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..53c47343 100644 --- a/flit_core/pyproject.toml +++ b/flit_core/pyproject.toml @@ -12,7 +12,7 @@ description = "Distribution-building parts of Flit. See flit package for more in 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..2558a3b1 100644 --- a/flit_core/tests_core/test_config.py +++ b/flit_core/tests_core/test_config.py @@ -1,4 +1,5 @@ import logging +import sys from pathlib import Path import pytest @@ -139,6 +140,27 @@ def test_bad_include_paths(path, err_match): ({'license': {'fromage': 2}}, '[Uu]nrecognised'), ({'license': {'file': 'LICENSE', 'text': 'xyz'}}, 'both'), ({'license': {}}, 'required'), + ({'license-files': 1}, r"\blist\b"), + ({'license-files': ["/LICENSE"]}, r"'/LICENSE'.+must not start with '/'"), + ({'license-files': ["../LICENSE"]}, r"'../LICENSE'.+must not contain '..'"), + ({'license-files': ["NOT_FOUND"]}, r"No files found.+'NOT_FOUND'"), + ({'license-files': ["(LICENSE | LICENCE)"]}, "Pattern contains invalid characters"), + pytest.param( + {'license-files': ["**LICENSE"]}, r"'\*\*LICENSE'.+Invalid pattern", + marks=[pytest.mark.skipif( + sys.version_info >= (3, 13), reason="Pattern is valid for 3.13+" + )] + ), + pytest.param( + {'license-files': ["./"]}, r"'./'.+Unacceptable pattern", + marks=[pytest.mark.skipif( + sys.version_info < (3, 13), reason="Pattern started to raise ValueError in 3.13" + )] + ), + ( + {'license': {'file': 'LICENSE'}, 'license-files': ["LICENSE"]}, + "license-files cannot be used with a license table", + ), ({'keywords': 'foo'}, 'list'), ({'keywords': ['foo', 7]}, 'strings'), ({'entry-points': {'foo': 'module1:main'}}, 'entry-point.*tables'), @@ -161,7 +183,7 @@ def test_bad_pep621_info(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') + config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml') @pytest.mark.parametrize(('readme', 'err_match'), [ ({'file': 'README.rst'}, 'required'), @@ -177,4 +199,29 @@ def test_bad_pep621_readme(readme, err_match): 'name': 'module1', 'version': '1.0', 'description': 'x', 'readme': readme } with pytest.raises(config.ConfigError, match=err_match): - config.read_pep621_metadata(proj, samples_dir / 'pep621') + config.read_pep621_metadata(proj, samples_dir / 'pep621' / 'pyproject.toml') + + +def test_license_file_defaults_with_old_metadata(): + metadata = {'module': 'mymod', 'author': ''} + info = config._prep_metadata(metadata, samples_dir / 'pep621_license_files' / 'pyproject.toml') + assert info.metadata['license_files'] == ["LICENSE"] + + +@pytest.mark.parametrize(('proj_license_files', 'files'), [ + ({}, ["LICENSE"]), # Only match default patterns + ({'license-files': []}, []), + ({'license-files': ["LICENSE"]}, ["LICENSE"]), + ({'license-files': ["LICENSE*"]}, ["LICENSE"]), + ({'license-files': ["LICEN[CS]E*"]}, ["LICENSE"]), + ({'license-files': ["**/LICENSE*"]}, ["LICENSE", "module/vendor/LICENSE_VENDOR"]), + ({'license-files': ["module/vendor/LICENSE*"]}, ["module/vendor/LICENSE_VENDOR"]), + ({'license-files': ["LICENSE", "module/**/LICENSE*"]}, ["LICENSE", "module/vendor/LICENSE_VENDOR"]), + # Add project.license.file + match default patterns + ({'license': {'file': 'module/vendor/LICENSE_VENDOR'}}, ["LICENSE", "module/vendor/LICENSE_VENDOR"]), +]) +def test_pep621_license_files(proj_license_files, files): + proj = {'name': 'module1', 'version': '1.0', 'description': 'x'} + proj.update(proj_license_files) + info = config.read_pep621_metadata(proj, samples_dir / 'pep621_license_files' / 'pyproject.toml') + assert info.metadata['license_files'] == files diff --git a/flit_core/tests_core/test_wheel.py b/flit_core/tests_core/test_wheel.py index 310f9c6c..a8f5252b 100644 --- a/flit_core/tests_core/test_wheel.py +++ b/flit_core/tests_core/test_wheel.py @@ -45,3 +45,11 @@ 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): + 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() 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')