Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Properly persist dynamic core metadata #1309

Merged
merged 1 commit into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions backend/src/hatchling/metadata/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import sys
from contextlib import suppress
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Generic, cast

Expand Down Expand Up @@ -83,6 +84,17 @@ def core_raw_metadata(self) -> dict[str, Any]:
message = 'The `project` configuration must be a table'
raise TypeError(message)

core_raw_metadata = deepcopy(core_raw_metadata)
pkg_info = os.path.join(self.root, 'PKG-INFO')
if os.path.isfile(pkg_info):
from hatchling.metadata.spec import project_metadata_from_core_metadata

with open(pkg_info, encoding='utf-8') as f:
pkg_info_contents = f.read()

base_metadata = project_metadata_from_core_metadata(pkg_info_contents)
core_raw_metadata.update(base_metadata)

self._core_raw_metadata = core_raw_metadata

return self._core_raw_metadata
Expand Down Expand Up @@ -126,8 +138,8 @@ def version(self) -> str:
"""
if self._version is None:
self._version = self._get_version()
if 'version' in self.dynamic and 'version' in self.core_raw_metadata['dynamic']:
self.core_raw_metadata['dynamic'].remove('version')
with suppress(ValueError):
self.core.dynamic.remove('version')

return self._version

Expand Down Expand Up @@ -1313,15 +1325,17 @@ def dynamic(self) -> list[str]:
https://peps.python.org/pep-0621/#dynamic
"""
if self._dynamic is None:
self._dynamic = self.config.get('dynamic', [])
dynamic = self.config.get('dynamic', [])

if not isinstance(dynamic, list):
message = 'Field `project.dynamic` must be an array'
raise TypeError(message)

if not isinstance(self._dynamic, list):
message = 'Field `project.dynamic` must be an array'
raise TypeError(message)
if not all(isinstance(entry, str) for entry in dynamic):
message = 'Field `project.dynamic` must only contain strings'
raise TypeError(message)

if not all(isinstance(entry, str) for entry in self._dynamic):
message = 'Field `project.dynamic` must only contain strings'
raise TypeError(message)
self._dynamic = sorted(dynamic)

return self._dynamic

Expand Down
207 changes: 206 additions & 1 deletion backend/src/hatchling/metadata/spec.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,50 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING, Any, Callable

if TYPE_CHECKING:
from hatchling.metadata.core import ProjectMetadata

DEFAULT_METADATA_VERSION = '2.2'
LATEST_METADATA_VERSION = '2.3'
CORE_METADATA_PROJECT_FIELDS = {
'Author': ('authors',),
'Author-email': ('authors',),
'Classifier': ('classifiers',),
'Description': ('readme',),
'Description-Content-Type': ('readme',),
'Dynamic': ('dynamic',),
'Keywords': ('keywords',),
'License': ('license',),
'License-Expression': ('license',),
'License-Files': ('license-files',),
'Maintainer': ('maintainers',),
'Maintainer-email': ('maintainers',),
'Name': ('name',),
'Provides-Extra': ('dependencies', 'optional-dependencies'),
'Requires-Dist': ('dependencies',),
'Requires-Python': ('requires-python',),
'Summary': ('description',),
'Project-URL': ('urls',),
'Version': ('version',),
}
PROJECT_CORE_METADATA_FIELDS = {
'authors': ('Author', 'Author-email'),
'classifiers': ('Classifier',),
'dependencies': ('Requires-Dist',),
'dynamic': ('Dynamic',),
'keywords': ('Keywords',),
'license': ('License', 'License-Expression'),
'license-files': ('License-Files',),
'maintainers': ('Maintainer', 'Maintainer-email'),
'name': ('Name',),
'optional-dependencies': ('Requires-Dist', 'Provides-Extra'),
'readme': ('Description', 'Description-Content-Type'),
'requires-python': ('Requires-Python',),
'description': ('Summary',),
'urls': ('Project-URL',),
'version': ('Version',),
}


def get_core_metadata_constructors() -> dict[str, Callable]:
Expand All @@ -20,6 +59,143 @@ def get_core_metadata_constructors() -> dict[str, Callable]:
}


def project_metadata_from_core_metadata(core_metadata: str) -> dict[str, Any]:
# https://packaging.python.org/en/latest/specifications/core-metadata/
import email
from email.headerregistry import HeaderRegistry

header_registry = HeaderRegistry()

message = email.message_from_string(core_metadata)
metadata = {}

if name := message.get('Name'):
metadata['name'] = name
else:
error_message = 'Missing required core metadata: Name'
raise ValueError(error_message)

if version := message.get('Version'):
metadata['version'] = version
else:
error_message = 'Missing required core metadata: Version'
raise ValueError(error_message)

if (dynamic_fields := message.get_all('Dynamic')) is not None:
# Use as an ordered set to retain bidirectional formatting.
# This likely doesn't matter but we try hard around here.
metadata['dynamic'] = list({
project_field: None
for core_metadata_field in dynamic_fields
for project_field in CORE_METADATA_PROJECT_FIELDS.get(core_metadata_field, ())
})

if description := message.get_payload():
metadata['readme'] = {
'content-type': message.get('Description-Content-Type', 'text/plain'),
'text': description,
}

if (license_expression := message.get('License-Expression')) is not None:
metadata['license'] = license_expression
elif (license_text := message.get('License')) is not None:
metadata['license'] = {'text': license_text}

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

if (summary := message.get('Summary')) is not None:
metadata['description'] = summary

if (keywords := message.get('Keywords')) is not None:
metadata['keywords'] = keywords.split(',')

if (classifiers := message.get_all('Classifier')) is not None:
metadata['classifiers'] = classifiers

if (project_urls := message.get_all('Project-URL')) is not None:
urls = {}
for project_url in project_urls:
label, url = project_url.split(',', maxsplit=1)
urls[label.strip()] = url.strip()
metadata['urls'] = urls

authors = []
if (author := message.get('Author')) is not None:
authors.append({'name': author})

if (author_email := message.get('Author-email')) is not None:
address_header = header_registry('resent-from', author_email)
for address in address_header.addresses: # type: ignore[attr-defined]
data = {'email': address.addr_spec}
if name := address.display_name:
data['name'] = name
authors.append(data)

if authors:
metadata['authors'] = authors

maintainers = []
if (maintainer := message.get('Maintainer')) is not None:
maintainers.append({'name': maintainer})

if (maintainer_email := message.get('Maintainer-email')) is not None:
address_header = header_registry('resent-from', maintainer_email)
for address in address_header.addresses: # type: ignore[attr-defined]
data = {'email': address.addr_spec}
if name := address.display_name:
data['name'] = name
maintainers.append(data)

if maintainers:
metadata['maintainers'] = maintainers

if (requires_python := message.get('Requires-Python')) is not None:
metadata['requires-python'] = requires_python

optional_dependencies: dict[str, list[str]] = {}
if (extras := message.get_all('Provides-Extra')) is not None:
for extra in extras:
optional_dependencies[extra] = []

if (requirements := message.get_all('Requires-Dist')) is not None:
from packaging.requirements import Requirement

dependencies = []
for requirement in requirements:
req = Requirement(requirement)
if req.marker is None:
dependencies.append(str(req))
continue

markers = req.marker._markers # noqa: SLF001
for i, marker in enumerate(markers):
if isinstance(marker, tuple):
left, _, right = marker
if left.value == 'extra':
extra = right.value
del markers[i]
# If there was only one marker then there will be an unnecessary
# trailing semicolon in the string representation
if not markers:
req.marker = None
# Otherwise we need to remove the preceding `and` operation
else:
del markers[i - 1]

optional_dependencies.setdefault(extra, []).append(str(req))
break
else:
dependencies.append(str(req))

metadata['dependencies'] = dependencies

if optional_dependencies:
metadata['optional-dependencies'] = optional_dependencies

return metadata


def construct_metadata_file_1_2(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str:
"""
https://peps.python.org/pep-0345/
Expand Down Expand Up @@ -169,6 +345,15 @@ def construct_metadata_file_2_2(metadata: ProjectMetadata, extra_dependencies: t
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'

Expand Down Expand Up @@ -251,6 +436,15 @@ def construct_metadata_file_2_3(metadata: ProjectMetadata, extra_dependencies: t
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'

Expand All @@ -270,6 +464,17 @@ def construct_metadata_file_2_3(metadata: ProjectMetadata, extra_dependencies: t
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.license_expression:
metadata_file += f'License-Expression: {metadata.core.license_expression}\n'

Expand Down
2 changes: 2 additions & 0 deletions docs/history/hatchling.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Remove `editables` as a direct dependency
- Fix default wheel tag when the supported Python version declaration is strict
- Load VCS ignore patterns first so that whitelisted patterns can be excluded by project configuration
- Metadata for the `wheel` target now defaults to the `PKG-INFO` metadata within source distributions
- Properly support core metadata version 2.2

## [1.21.1](https://github.com/pypa/hatch/releases/tag/hatchling-v1.21.1) - 2024-01-25 ## {: #hatchling-v1.21.1 }

Expand Down
2 changes: 1 addition & 1 deletion tests/backend/builders/plugin/test_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def test_core(self, isolation):
config = {'project': {}}
builder = MockBuilder(str(isolation), config=config)

assert builder.project_config is builder.project_config is config['project']
assert builder.project_config == builder.project_config == config['project']

def test_hatch(self, isolation):
config = {'tool': {'hatch': {}}}
Expand Down
Loading