from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from collections.abc import Callable from hatchling.metadata.core import ProjectMetadata DEFAULT_METADATA_VERSION = "2.4" LATEST_METADATA_VERSION = "2.4" 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]: """ https://packaging.python.org/specifications/core-metadata/ """ return { "1.2": construct_metadata_file_1_2, "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, } 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: dict[str, Any] = {} 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"] = 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] # noqa: B909 # 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/ """ metadata_file = "Metadata-Version: 1.2\n" metadata_file += f"Name: {metadata.core.raw_name}\n" metadata_file += f"Version: {metadata.version}\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" elif metadata.core.license_expression: metadata_file += f"License: {metadata.core.license_expression}\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" return metadata_file def construct_metadata_file_2_1(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str: """ https://peps.python.org/pep-0566/ """ metadata_file = "Metadata-Version: 2.1\n" metadata_file += f"Name: {metadata.core.raw_name}\n" metadata_file += f"Version: {metadata.version}\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" elif metadata.core.license_expression: metadata_file += f"License: {metadata.core.license_expression}\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_2(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str: """ https://peps.python.org/pep-0643/ """ metadata_file = "Metadata-Version: 2.2\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" elif metadata.core.license_expression: metadata_file += f"License: {metadata.core.license_expression}\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_3(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str: """ 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" elif metadata.core.license_expression: metadata_file += f"License: {metadata.core.license_expression}\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 { 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.license_expression: metadata_file += f"License-Expression: {metadata.core.license_expression}\n" if metadata.core.license_files: for license_file in metadata.core.license_files: metadata_file += f"License-File: {license_file}\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