Skip to content

Commit

Permalink
Update the default version of core metadata to 2.4 (#1790)
Browse files Browse the repository at this point in the history
  • Loading branch information
ofek authored Nov 9, 2024
1 parent f8a2eaa commit 28f233c
Show file tree
Hide file tree
Showing 6 changed files with 558 additions and 127 deletions.
66 changes: 18 additions & 48 deletions backend/src/hatchling/metadata/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -734,65 +734,35 @@ def license_files(self) -> list[str]:
https://peps.python.org/pep-0639/
"""
if self._license_files is None:
if 'license-files' not in self.config:
data = {'globs': ['LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*']}
else:
if 'license-files' in self.config:
globs = self.config['license-files']
if 'license-files' in self.dynamic:
message = (
'Metadata field `license-files` cannot be both statically defined and '
'listed in field `project.dynamic`'
)
raise ValueError(message)
else:
globs = ['LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*']

data = self.config['license-files']
if not isinstance(data, dict):
message = 'Field `project.license-files` must be a table'
raise TypeError(message)

if 'paths' in data and 'globs' in data:
message = 'Cannot specify both `paths` and `globs` in the `project.license-files` table'
raise ValueError(message)

license_files = []
if 'paths' in data:
paths = data['paths']
if not isinstance(paths, list):
message = 'Field `paths` in the `project.license-files` table must be an array'
raise TypeError(message)

for i, relative_path in enumerate(paths, 1):
if not isinstance(relative_path, str):
message = f'Entry #{i} in field `paths` in the `project.license-files` table must be a string'
raise TypeError(message)
from glob import glob

path = os.path.normpath(os.path.join(self.root, relative_path))
if not os.path.isfile(path):
message = f'License file does not exist: {relative_path}'
raise OSError(message)

license_files.append(os.path.relpath(path, self.root).replace('\\', '/'))
elif 'globs' in data:
from glob import glob
license_files: list[str] = []
if not isinstance(globs, list):
message = 'Field `project.license-files` must be an array'
raise TypeError(message)

globs = data['globs']
if not isinstance(globs, list):
message = 'Field `globs` in the `project.license-files` table must be an array'
for i, pattern in enumerate(globs, 1):
if not isinstance(pattern, str):
message = f'Entry #{i} of field `project.license-files` must be a string'
raise TypeError(message)

for i, pattern in enumerate(globs, 1):
if not isinstance(pattern, str):
message = f'Entry #{i} in field `globs` in the `project.license-files` table must be a string'
raise TypeError(message)

full_pattern = os.path.normpath(os.path.join(self.root, pattern))
license_files.extend(
os.path.relpath(path, self.root).replace('\\', '/')
for path in glob(full_pattern)
if os.path.isfile(path)
)
else:
message = 'Must specify either `paths` or `globs` in the `project.license-files` table if defined'
raise ValueError(message)
full_pattern = os.path.normpath(os.path.join(self.root, pattern))
license_files.extend(
os.path.relpath(path, self.root).replace('\\', '/')
for path in glob(full_pattern)
if os.path.isfile(path)
)

self._license_files = sorted(license_files)

Expand Down
93 changes: 89 additions & 4 deletions backend/src/hatchling/metadata/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
if TYPE_CHECKING:
from hatchling.metadata.core import ProjectMetadata

DEFAULT_METADATA_VERSION = '2.3'
LATEST_METADATA_VERSION = '2.3'
DEFAULT_METADATA_VERSION = '2.4'
LATEST_METADATA_VERSION = '2.4'
CORE_METADATA_PROJECT_FIELDS = {
'Author': ('authors',),
'Author-email': ('authors',),
Expand Down Expand Up @@ -56,6 +56,7 @@ def get_core_metadata_constructors() -> dict[str, Callable]:
'2.1': construct_metadata_file_2_1,
'2.2': construct_metadata_file_2_2,
'2.3': construct_metadata_file_2_3,
'2.4': construct_metadata_file_2_4,
}


Expand Down Expand Up @@ -102,7 +103,7 @@ def project_metadata_from_core_metadata(core_metadata: str) -> dict[str, Any]:
metadata['license'] = {'text': license_text}

if (license_files := message.get_all('License-File')) is not None:
metadata['license-files'] = {'paths': license_files}
metadata['license-files'] = license_files

if (summary := message.get('Summary')) is not None:
metadata['description'] = summary
Expand Down Expand Up @@ -430,12 +431,96 @@ def construct_metadata_file_2_2(metadata: ProjectMetadata, extra_dependencies: t

def construct_metadata_file_2_3(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str:
"""
https://peps.python.org/pep-0639/
https://peps.python.org/pep-0685/
"""
metadata_file = 'Metadata-Version: 2.3\n'
metadata_file += f'Name: {metadata.core.raw_name}\n'
metadata_file += f'Version: {metadata.version}\n'

if metadata.core.dynamic:
# Ordered set
for field in {
core_metadata_field: None
for project_field in metadata.core.dynamic
for core_metadata_field in PROJECT_CORE_METADATA_FIELDS.get(project_field, ())
}:
metadata_file += f'Dynamic: {field}\n'

if metadata.core.description:
metadata_file += f'Summary: {metadata.core.description}\n'

if metadata.core.urls:
for label, url in metadata.core.urls.items():
metadata_file += f'Project-URL: {label}, {url}\n'

authors_data = metadata.core.authors_data
if authors_data['name']:
metadata_file += f"Author: {', '.join(authors_data['name'])}\n"
if authors_data['email']:
metadata_file += f"Author-email: {', '.join(authors_data['email'])}\n"

maintainers_data = metadata.core.maintainers_data
if maintainers_data['name']:
metadata_file += f"Maintainer: {', '.join(maintainers_data['name'])}\n"
if maintainers_data['email']:
metadata_file += f"Maintainer-email: {', '.join(maintainers_data['email'])}\n"

if metadata.core.license:
license_start = 'License: '
indent = ' ' * (len(license_start) - 1)
metadata_file += license_start

for i, line in enumerate(metadata.core.license.splitlines()):
if i == 0:
metadata_file += f'{line}\n'
else:
metadata_file += f'{indent}{line}\n'

if metadata.core.keywords:
metadata_file += f"Keywords: {','.join(metadata.core.keywords)}\n"

if metadata.core.classifiers:
for classifier in metadata.core.classifiers:
metadata_file += f'Classifier: {classifier}\n'

if metadata.core.requires_python:
metadata_file += f'Requires-Python: {metadata.core.requires_python}\n'

if metadata.core.dependencies:
for dependency in metadata.core.dependencies:
metadata_file += f'Requires-Dist: {dependency}\n'

if extra_dependencies:
for dependency in extra_dependencies:
metadata_file += f'Requires-Dist: {dependency}\n'

if metadata.core.optional_dependencies:
for option, dependencies in metadata.core.optional_dependencies.items():
metadata_file += f'Provides-Extra: {option}\n'
for dependency in dependencies:
if ';' in dependency:
dep_name, dep_env_marker = dependency.split(';', maxsplit=1)
metadata_file += f'Requires-Dist: {dep_name}; ({dep_env_marker.strip()}) and extra == {option!r}\n'
elif '@ ' in dependency:
metadata_file += f'Requires-Dist: {dependency} ; extra == {option!r}\n'
else:
metadata_file += f'Requires-Dist: {dependency}; extra == {option!r}\n'

if metadata.core.readme:
metadata_file += f'Description-Content-Type: {metadata.core.readme_content_type}\n'
metadata_file += f'\n{metadata.core.readme}'

return metadata_file


def construct_metadata_file_2_4(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str:
"""
https://peps.python.org/pep-0639/
"""
metadata_file = 'Metadata-Version: 2.4\n'
metadata_file += f'Name: {metadata.core.raw_name}\n'
metadata_file += f'Version: {metadata.version}\n'

if metadata.core.dynamic:
# Ordered set
for field in {
Expand Down
5 changes: 5 additions & 0 deletions docs/history/hatchling.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

***Added:***

- Update the default version of core metadata to 2.4
- Bump the minimum supported version of `packaging` to 24.2

***Fixed:***

- No longer write package metadata for license expressions and files for versions of core metadata prior to 2.4

## [1.25.0](https://github.com/pypa/hatch/releases/tag/hatchling-v1.25.0) - 2024-06-22 ## {: #hatchling-v1.25.0 }

***Changed:***
Expand Down
2 changes: 1 addition & 1 deletion tests/backend/builders/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -909,7 +909,7 @@ def test_default_multiple_licenses(self, hatch, helpers, config_file, temp_dir):
(project_path / 'LICENSES' / 'test').mkdir()

config = {
'project': {'name': project_name, 'dynamic': ['version'], 'license-files': {'globs': ['LICENSES/*']}},
'project': {'name': project_name, 'dynamic': ['version'], 'license-files': ['LICENSES/*']},
'tool': {
'hatch': {
'version': {'path': 'my_app/__about__.py'},
Expand Down
68 changes: 8 additions & 60 deletions tests/backend/metadata/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,54 +621,16 @@ def test_dynamic(self, isolation):
):
_ = metadata.core.license_files

def test_not_table(self, isolation):
def test_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': 9000}})

with pytest.raises(TypeError, match='Field `project.license-files` must be a table'):
_ = metadata.core.license_files

def test_multiple_options(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': {'paths': [], 'globs': []}}})

with pytest.raises(
ValueError, match='Cannot specify both `paths` and `globs` in the `project.license-files` table'
):
_ = metadata.core.license_files

def test_no_option(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': {}}})

with pytest.raises(
ValueError, match='Must specify either `paths` or `globs` in the `project.license-files` table if defined'
):
_ = metadata.core.license_files

def test_paths_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': {'paths': 9000}}})

with pytest.raises(TypeError, match='Field `paths` in the `project.license-files` table must be an array'):
with pytest.raises(TypeError, match='Field `project.license-files` must be an array'):
_ = metadata.core.license_files

def test_paths_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': {'paths': [9000]}}})

with pytest.raises(
TypeError, match='Entry #1 in field `paths` in the `project.license-files` table must be a string'
):
_ = metadata.core.license_files

def test_globs_not_array(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': {'globs': 9000}}})

with pytest.raises(TypeError, match='Field `globs` in the `project.license-files` table must be an array'):
_ = metadata.core.license_files

def test_globs_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': {'globs': [9000]}}})
def test_entry_not_string(self, isolation):
metadata = ProjectMetadata(str(isolation), None, {'project': {'license-files': [9000]}})

with pytest.raises(
TypeError, match='Entry #1 in field `globs` in the `project.license-files` table must be a string'
):
with pytest.raises(TypeError, match='Entry #1 of field `project.license-files` must be a string'):
_ = metadata.core.license_files

def test_default_globs_no_licenses(self, isolation):
Expand All @@ -693,7 +655,7 @@ def test_default_globs_with_licenses(self, temp_dir):
assert metadata.core.license_files == sorted(expected)

def test_globs_with_licenses(self, temp_dir):
metadata = ProjectMetadata(str(temp_dir), None, {'project': {'license-files': {'globs': ['LICENSES/*']}}})
metadata = ProjectMetadata(str(temp_dir), None, {'project': {'license-files': ['LICENSES/*']}})

licenses_dir = temp_dir / 'LICENSES'
licenses_dir.mkdir()
Expand All @@ -709,7 +671,7 @@ def test_paths_with_licenses(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
None,
{'project': {'license-files': {'paths': ['LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt', 'COPYING']}}},
{'project': {'license-files': ['LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt', 'COPYING']}},
)

licenses_dir = temp_dir / 'LICENSES'
Expand All @@ -722,20 +684,6 @@ def test_paths_with_licenses(self, temp_dir):

assert metadata.core.license_files == ['COPYING', 'LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt']

def test_paths_missing_license(self, temp_dir):
metadata = ProjectMetadata(
str(temp_dir),
None,
{'project': {'license-files': {'paths': ['LICENSES/MIT.txt']}}},
)

licenses_dir = temp_dir / 'LICENSES'
licenses_dir.mkdir()
(licenses_dir / 'Apache-2.0.txt').touch()

with pytest.raises(OSError, match='License file does not exist: LICENSES/MIT.txt'):
_ = metadata.core.license_files


class TestAuthors:
def test_dynamic(self, isolation):
Expand Down Expand Up @@ -1661,7 +1609,7 @@ def test_license_files(self, temp_dir, latest_spec):
raw_metadata = {
'name': 'My.App',
'version': '0.0.1',
'license-files': {'paths': ['LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt']},
'license-files': ['LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt'],
}
metadata = ProjectMetadata(str(temp_dir), None, {'project': raw_metadata})

Expand Down
Loading

0 comments on commit 28f233c

Please sign in to comment.