From f3b2159a8c4221062692881774bc58dfed5aaa76 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 11 Mar 2024 23:29:05 -0400 Subject: [PATCH] Properly persist dynamic core metadata (#1309) --- backend/src/hatchling/metadata/core.py | 32 +- backend/src/hatchling/metadata/spec.py | 207 ++++++++++++- docs/history/hatchling.md | 2 + .../backend/builders/plugin/test_interface.py | 2 +- tests/backend/metadata/test_core.py | 162 +++++++++- tests/backend/metadata/test_spec.py | 279 +++++++++++++++++- 6 files changed, 671 insertions(+), 13 deletions(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 4dc9774a1..cc53be2fc 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -2,6 +2,7 @@ import os import sys +from contextlib import suppress from copy import deepcopy from typing import TYPE_CHECKING, Any, Generic, cast @@ -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 @@ -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 @@ -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 diff --git a/backend/src/hatchling/metadata/spec.py b/backend/src/hatchling/metadata/spec.py index 477243787..c3616a268 100644 --- a/backend/src/hatchling/metadata/spec.py +++ b/backend/src/hatchling/metadata/spec.py @@ -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]: @@ -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/ @@ -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' @@ -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' @@ -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' diff --git a/docs/history/hatchling.md b/docs/history/hatchling.md index 28f400d22..7387515c5 100644 --- a/docs/history/hatchling.md +++ b/docs/history/hatchling.md @@ -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 } diff --git a/tests/backend/builders/plugin/test_interface.py b/tests/backend/builders/plugin/test_interface.py index 16afe2d24..ac93fe84d 100644 --- a/tests/backend/builders/plugin/test_interface.py +++ b/tests/backend/builders/plugin/test_interface.py @@ -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': {}}} diff --git a/tests/backend/metadata/test_core.py b/tests/backend/metadata/test_core.py index 86f574a56..f9aaa2f6d 100644 --- a/tests/backend/metadata/test_core.py +++ b/tests/backend/metadata/test_core.py @@ -1,11 +1,21 @@ import pytest from hatchling.metadata.core import BuildMetadata, CoreMetadata, HatchMetadata, ProjectMetadata +from hatchling.metadata.spec import ( + LATEST_METADATA_VERSION, + get_core_metadata_constructors, + project_metadata_from_core_metadata, +) from hatchling.plugin.manager import PluginManager from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT from hatchling.version.source.regex import RegexSource +@pytest.fixture(scope='module') +def latest_spec(): + return get_core_metadata_constructors()[LATEST_METADATA_VERSION] + + class TestConfig: def test_default(self, isolation): metadata = ProjectMetadata(str(isolation), None) @@ -84,7 +94,6 @@ def test_correct(self, isolation): dynamic = ['version'] metadata = ProjectMetadata(str(isolation), None, {'project': {'dynamic': dynamic}}) - assert metadata.core.dynamic is dynamic assert metadata.core.dynamic == ['version'] def test_cache_not_array(self, isolation): @@ -1539,3 +1548,154 @@ def test_precedence(self, temp_dir, helpers): assert metadata.version == '0.0.2' assert metadata.hatch.build_config['reproducible'] is False + + +class TestMetadataConversion: + def test_required_only(self, isolation, latest_spec): + raw_metadata = {'name': 'My.App', 'version': '0.0.1'} + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_dynamic(self, isolation, latest_spec): + raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'dynamic': ['authors', 'classifiers']} + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_description(self, isolation, latest_spec): + raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'description': 'foo bar'} + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_urls(self, isolation, latest_spec): + raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'urls': {'foo': 'bar', 'bar': 'baz'}} + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_authors(self, isolation, latest_spec): + raw_metadata = { + 'name': 'My.App', + 'version': '0.0.1', + 'authors': [{'name': 'foobar'}, {'email': 'bar@domain', 'name': 'foo'}, {'email': 'baz@domain'}], + } + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_maintainers(self, isolation, latest_spec): + raw_metadata = { + 'name': 'My.App', + 'version': '0.0.1', + 'maintainers': [{'name': 'foobar'}, {'email': 'bar@domain', 'name': 'foo'}, {'email': 'baz@domain'}], + } + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_keywords(self, isolation, latest_spec): + raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'keywords': ['bar', 'foo']} + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_classifiers(self, isolation, latest_spec): + raw_metadata = { + 'name': 'My.App', + 'version': '0.0.1', + 'classifiers': ['Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.11'], + } + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + 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']}, + } + metadata = ProjectMetadata(str(temp_dir), None, {'project': raw_metadata}) + + licenses_path = temp_dir / 'LICENSES' + licenses_path.mkdir() + licenses_path.joinpath('Apache-2.0.txt').touch() + licenses_path.joinpath('MIT.txt').touch() + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_license_expression(self, isolation, latest_spec): + raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'license': 'MIT'} + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_license_legacy(self, isolation, latest_spec): + raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'license': {'text': 'foo'}} + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_readme(self, isolation, latest_spec): + raw_metadata = { + 'name': 'My.App', + 'version': '0.0.1', + 'readme': {'content-type': 'text/markdown', 'text': 'test content\n'}, + } + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_requires_python(self, isolation, latest_spec): + raw_metadata = {'name': 'My.App', 'version': '0.0.1', 'requires-python': '<2,>=1'} + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + def test_dependencies(self, isolation, latest_spec): + raw_metadata = { + 'name': 'My.App', + 'version': '0.0.1', + 'dependencies': ['bar==5', 'foo==1'], + 'optional-dependencies': { + 'feature1': ['bar==5; python_version < "3"', 'foo==1'], + 'feature2': ['bar==5', 'foo==1; python_version < "3"'], + }, + } + metadata = ProjectMetadata(str(isolation), None, {'project': raw_metadata}) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == raw_metadata + + +def test_source_distribution_metadata(temp_dir, helpers, latest_spec): + metadata = ProjectMetadata(str(temp_dir), None, {'project': {}}) + + pkg_info = temp_dir / 'PKG-INFO' + pkg_info.write_text( + helpers.dedent( + f""" + Metadata-Version: {LATEST_METADATA_VERSION} + Name: My.App + Version: 0.0.1 + """ + ) + ) + + core_metadata = latest_spec(metadata) + assert project_metadata_from_core_metadata(core_metadata) == {'name': 'My.App', 'version': '0.0.1'} diff --git a/tests/backend/metadata/test_spec.py b/tests/backend/metadata/test_spec.py index 18dadd6aa..50a9b6073 100644 --- a/tests/backend/metadata/test_spec.py +++ b/tests/backend/metadata/test_spec.py @@ -1,7 +1,233 @@ import pytest from hatchling.metadata.core import ProjectMetadata -from hatchling.metadata.spec import get_core_metadata_constructors +from hatchling.metadata.spec import ( + LATEST_METADATA_VERSION, + get_core_metadata_constructors, + project_metadata_from_core_metadata, +) + + +class TestProjectMetadataFromCoreMetadata: + def test_missing_name(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +""" + with pytest.raises(ValueError, match='^Missing required core metadata: Name$'): + project_metadata_from_core_metadata(core_metadata) + + def test_missing_version(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +""" + with pytest.raises(ValueError, match='^Missing required core metadata: Version$'): + project_metadata_from_core_metadata(core_metadata) + + def test_dynamic(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Dynamic: Classifier +Dynamic: Provides-Extra +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'dynamic': ['classifiers', 'dependencies', 'optional-dependencies'], + } + + def test_description(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Summary: foo +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'description': 'foo', + } + + def test_urls(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Project-URL: foo, bar +Project-URL: bar, baz +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'urls': {'foo': 'bar', 'bar': 'baz'}, + } + + def test_authors(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Author: foobar +Author-email: foo , +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'authors': [{'name': 'foobar'}, {'email': 'bar@domain', 'name': 'foo'}, {'email': 'baz@domain'}], + } + + def test_maintainers(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Maintainer: foobar +Maintainer-email: foo , +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'maintainers': [{'name': 'foobar'}, {'email': 'bar@domain', 'name': 'foo'}, {'email': 'baz@domain'}], + } + + def test_keywords(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Keywords: bar,foo +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'keywords': ['bar', 'foo'], + } + + def test_classifiers(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.11 +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'classifiers': ['Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.11'], + } + + def test_license_files(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +License-File: LICENSES/Apache-2.0.txt +License-File: LICENSES/MIT.txt +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'license-files': {'paths': ['LICENSES/Apache-2.0.txt', 'LICENSES/MIT.txt']}, + } + + def test_license_expression(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +License-Expression: MIT +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'license': 'MIT', + } + + def test_license_legacy(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +License: foo +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'license': {'text': 'foo'}, + } + + def test_readme(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Description-Content-Type: text/markdown + +test content +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'readme': {'content-type': 'text/markdown', 'text': 'test content\n'}, + } + + def test_readme_default_content_type(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 + +test content +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'readme': {'content-type': 'text/plain', 'text': 'test content\n'}, + } + + def test_requires_python(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Requires-Python: <2,>=1 +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'requires-python': '<2,>=1', + } + + def test_dependencies(self): + core_metadata = f"""\ +Metadata-Version: {LATEST_METADATA_VERSION} +Name: My.App +Version: 0.1.0 +Requires-Dist: bar==5 +Requires-Dist: foo==1 +Provides-Extra: feature1 +Requires-Dist: bar==5; (python_version < '3') and extra == 'feature1' +Requires-Dist: foo==1; extra == 'feature1' +Provides-Extra: feature2 +Requires-Dist: bar==5; extra == 'feature2' +Requires-Dist: foo==1; (python_version < '3') and extra == 'feature2' +Provides-Extra: feature3 +Requires-Dist: baz@ file:///path/to/project ; extra == 'feature3' +""" + assert project_metadata_from_core_metadata(core_metadata) == { + 'name': 'My.App', + 'version': '0.1.0', + 'dependencies': ['bar==5', 'foo==1'], + 'optional-dependencies': { + 'feature1': ['bar==5; python_version < "3"', 'foo==1'], + 'feature2': ['bar==5', 'foo==1; python_version < "3"'], + 'feature3': ['baz@ file:///path/to/project'], + }, + } @pytest.mark.parametrize('constructor', [get_core_metadata_constructors()['1.2']]) @@ -770,6 +996,24 @@ def test_default(self, constructor, isolation, helpers): """ ) + def test_dynamic(self, constructor, isolation, helpers): + metadata = ProjectMetadata( + str(isolation), + None, + {'project': {'name': 'My.App', 'version': '0.1.0', 'dynamic': ['authors', 'classifiers']}}, + ) + + assert constructor(metadata) == helpers.dedent( + """ + Metadata-Version: 2.2 + Name: My.App + Version: 0.1.0 + Dynamic: Author + Dynamic: Author-email + Dynamic: Classifier + """ + ) + def test_description(self, constructor, isolation, helpers): metadata = ProjectMetadata( str(isolation), None, {'project': {'name': 'My.App', 'version': '0.1.0', 'description': 'foo'}} @@ -1236,6 +1480,24 @@ def test_description(self, constructor, isolation, helpers): """ ) + def test_dynamic(self, constructor, isolation, helpers): + metadata = ProjectMetadata( + str(isolation), + None, + {'project': {'name': 'My.App', 'version': '0.1.0', 'dynamic': ['authors', 'classifiers']}}, + ) + + assert constructor(metadata) == helpers.dedent( + """ + Metadata-Version: 2.3 + Name: My.App + Version: 0.1.0 + Dynamic: Author + Dynamic: Author-email + Dynamic: Classifier + """ + ) + def test_urls(self, constructor, isolation, helpers): metadata = ProjectMetadata( str(isolation), @@ -1383,6 +1645,21 @@ def test_maintainers_multiple(self, constructor, isolation, helpers): """ ) + def test_license(self, constructor, isolation, helpers): + metadata = ProjectMetadata( + str(isolation), None, {'project': {'name': 'My.App', 'version': '0.1.0', 'license': {'text': 'foo\nbar'}}} + ) + + assert constructor(metadata) == helpers.dedent( + """ + Metadata-Version: 2.3 + Name: My.App + Version: 0.1.0 + License: foo + bar + """ + ) + def test_license_expression(self, constructor, isolation, helpers): metadata = ProjectMetadata( str(isolation),