Skip to content

Commit

Permalink
Merge pull request #705 from cdce8p/license-files
Browse files Browse the repository at this point in the history
Add initial support for license-files
  • Loading branch information
takluyver authored Feb 7, 2025
2 parents 432042e + 3d7895a commit dc30bf8
Show file tree
Hide file tree
Showing 13 changed files with 185 additions and 9 deletions.
3 changes: 3 additions & 0 deletions doc/pyproject_toml.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions flit_core/flit_core/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ class Metadata(object):
obsoletes_dist = ()
requires_external = ()
provides_extra = ()
license_files = ()
dynamic = ()

metadata_version = "2.3"
Expand Down Expand Up @@ -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))
Expand Down
72 changes: 71 additions & 1 deletion flit_core/flit_core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class ConfigError(ValueError):
'readme',
'requires-python',
'license',
'license-files',
'authors',
'maintainers',
'keywords',
Expand All @@ -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.
Expand Down Expand Up @@ -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):
Expand All @@ -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(
Expand Down Expand Up @@ -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']
Expand All @@ -543,14 +594,33 @@ 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:
raise ConfigError(
"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']))
Expand Down
6 changes: 2 additions & 4 deletions flit_core/flit_core/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion flit_core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions flit_core/tests_core/samples/pep621_license_files/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file should be added to wheels
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Readme
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file should be added to wheels
3 changes: 3 additions & 0 deletions flit_core/tests_core/samples/pep621_license_files/module1a.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Example module"""

__version__ = '0.1'
39 changes: 39 additions & 0 deletions flit_core/tests_core/samples/pep621_license_files/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]"}
]
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"
51 changes: 49 additions & 2 deletions flit_core/tests_core/test_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import sys
from pathlib import Path
import pytest

Expand Down Expand Up @@ -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'),
Expand All @@ -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'),
Expand All @@ -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
8 changes: 8 additions & 0 deletions flit_core/tests_core/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion tests/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down

0 comments on commit dc30bf8

Please sign in to comment.