Created
October 16, 2022 18:00
-
-
Save brettcannon/731ddd584bad01a5ee678d332a932041 to your computer and use it in GitHub Desktop.
`packaging.metadata` with lazy data validation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import pathlib | |
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union | |
from .requirements import Requirement | |
from .specifiers import SpecifierSet | |
from .utils import NormalizedName, canonicalize_name | |
from .version import Version | |
class Metadata: | |
"""A class representing the `Core Metadata`_ for a project. | |
Every potential metadata field except for ``Metadata-Version`` is represented by a | |
parameter to the class' constructor. The required metadata can be passed in | |
positionally or via keyword, while all optional metadata can only be passed in via | |
keyword. | |
Every parameter has a matching attribute with a ``raw_`` name prefix. These | |
attributes store the unprocessed data as provided to the instance. *Some* parameters | |
have a matching attribute with a ``canonical_`` name prefix. These attributes | |
provide the normalized/canonical versions of the equivalent ``raw_`` attribute. The | |
parameters with a matching canonical attribute will accept either the raw or | |
canonical version of the data. | |
The ``canonical_`` attributes are lazy, thus potentially raising an exception upon | |
access if the underlying ``raw_`` data is malformed. Setting a ``canonical_`` | |
attribute will update both it and the ``raw_`` attribute, while updating a ``raw_`` | |
attribute will reset the ``canonical_`` attribute, leading to a recalculation upon | |
the next access of the ``canonical_`` attribute. This allows one to read "raw" | |
metadata and only normalize data on-demand, avoiding malformed data which is | |
inconsequential from interfering. | |
""" | |
_raw_name: str | |
_canonical_name: Optional[NormalizedName] | |
_raw_version: str | |
_canonical_version: Optional[Version] | |
raw_platforms: List[str] | |
raw_summary: str | |
raw_description: str | |
raw_keywords: str | |
raw_home_page: str | |
raw_author: str | |
raw_author_email: str | |
raw_license: str | |
raw_supported_platforms: List[str] | |
raw_download_url: str | |
raw_classifiers: List[str] | |
raw_maintainer: str | |
raw_maintainer_email: str | |
_raw_requires_dists: List[str] | |
_canonical_requires_dists: Optional[List[Requirement]] | |
_raw_requires_python: str | |
_canonical_requires_python: Optional[SpecifierSet] | |
raw_requires_externals: List[str] | |
raw_project_urls: List[str] | |
raw_provides_dists: List[str] | |
raw_obsoletes_dists: List[str] | |
raw_description_content_type: str | |
_raw_provides_extras: List[str] | |
_canonical_provides_extras: Optional[List[NormalizedName]] | |
_raw_dynamic: List[str] | |
_canonical_dynamic: Optional[List[str]] | |
def __init__( | |
self, | |
name: str, | |
version: Version | str, | |
*, | |
# 1.0 | |
platforms: Optional[Iterable[str]] = None, | |
summary: Optional[str] = None, | |
description: Optional[str] = None, | |
keywords: Optional[str] = None, | |
home_page: Optional[str] = None, | |
author: Optional[str] = None, | |
author_email: Optional[str] = None, | |
license: Optional[str] = None, | |
# 1.1 | |
supported_platforms: Optional[Iterable[str]] = None, | |
download_url: Optional[str] = None, | |
classifiers: Optional[Iterable[str]] = None, | |
# 1.2 | |
maintainer: Optional[str] = None, | |
maintainer_email: Optional[str] = None, | |
requires_dists: Union[Iterable[Requirement], Iterable[str], None] = None, | |
requires_python: Union[SpecifierSet, str, None] = None, | |
requires_externals: Optional[Iterable[str]] = None, | |
project_urls: Optional[Iterable[str]] = None, | |
provides_dists: Optional[Iterable[str]] = None, | |
obsoletes_dists: Optional[Iterable[str]] = None, | |
# 2.1 | |
description_content_type: Optional[str] = None, | |
provides_extras: Optional[Iterable[str]] = None, | |
# 2.2 | |
dynamic: Optional[Iterable[str]] = None, | |
) -> None: | |
"""Initialize a Metadata object. | |
The parameters all correspond to fields in `Core Metadata`_. | |
:param name: ``Name`` | |
:param version: ``Version`` | |
:param platforms: ``Platform`` | |
:param summary: ``Summary`` | |
:param description: ``Description`` | |
:param keywords: ``Keywords`` | |
:param home_page: ``Home-Page`` | |
:param author: ``Author`` | |
:param author_email: ``Author-Email`` | |
:param license: ``License`` | |
:param supported_platforms: ``Supported-Platform`` | |
:param download_url: ``Download-URL`` | |
:param classifiers: ``Classifier`` | |
:param maintainer: ``Maintainer`` | |
:param maintainer_email: ``Maintainer-Email`` | |
:param requires_dists: ``Requires-Dist`` | |
:param requires_python: ``Requires-Python`` | |
:param requires_externals: ``Requires-External`` | |
:param project_urls: ``Project-URL`` | |
:param provides_dists: ``Provides-Dist`` | |
:param obsoletes_dists: ``Obsoletes-Dist`` | |
:param description_content_type: ``Description-Content-Type`` | |
:param provides_extras: ``Provides-Extra`` | |
:param dynamic: ``Dynamic`` | |
""" | |
self._raw_name = name | |
self._canonical_name = None | |
if isinstance(version, Version): | |
self._canonical_version = version | |
self._raw_version = str(Version) | |
else: | |
self._canonical_version = None | |
self._raw_version = version | |
self.raw_platforms = list(platforms or []) | |
self.raw_summary = summary or "" | |
self.raw_description = description or "" | |
self.raw_keywords = keywords or "" | |
self.raw_home_page = home_page or "" | |
self.raw_author = author or "" | |
self.raw_author_emails = author_email or "" | |
self.raw_license = license or "" | |
self.raw_supported_platforms = list(supported_platforms or []) | |
self.raw_download_url = download_url or "" | |
self.raw_classifiers = list(classifiers or []) | |
self.raw_maintainer = maintainer or "" | |
self.raw_maintainer_emails = maintainer_email or "" | |
requires_dists_list = list(requires_dists or []) | |
if len(requires_dists_list) > 0 and isinstance( | |
requires_dists_list[0], Requirement | |
): | |
self._canonical_requires_dists = requires_dists_list | |
self._raw_requires_dists = list(map(str, requires_dists_list)) | |
else: | |
self._canonical_requires_dists = None | |
self._raw_requires_dists = requires_dists_list | |
if isinstance(requires_python, SpecifierSet): | |
self._canonical_requires_python = requires_python | |
self._raw_requires_python = str(requires_python) | |
else: | |
self._canonical_requires_python = None | |
self._raw_requires_python = requires_python or "" | |
self.raw_requires_externals = list(requires_externals or []) | |
self.project_urls = list(project_urls or []) | |
self.raw_provides_dists = list(provides_dists or []) | |
self.raw_obsoletes_dists = list(obsoletes_dists or []) | |
self.raw_description_content_type = description_content_type or "" | |
self._raw_provides_extras = list(provides_extras or []) | |
self._canonical_provides_extras = None | |
self._raw_dynamic = list(dynamic or []) | |
self._canonical_dynamic = None | |
@property | |
def raw_name(self) -> str: | |
"""Return the unprocessed ``Name`` field data.""" | |
return self._raw_name | |
@raw_name.setter | |
def raw_name(self, value: str, /) -> None: | |
"""Set the ``Name`` field.""" | |
self._canonical_name = None | |
self._raw_name = value | |
@property | |
def canonical_name(self) -> NormalizedName: | |
"""Return the normalized version of ``Name``.""" | |
if self._canonical_name is None: | |
self._canonical_name = canonicalize_name(self._raw_name) | |
return self._canonical_name | |
@canonical_name.setter | |
def canonical_name(self, value: NormalizedName, /) -> None: | |
"""Set the ``Name`` field to its normalized version.""" | |
self._raw_name = self._canonical_name = value | |
@property | |
def raw_version(self) -> str: | |
"""Return the unprocessed ``Version`` field data.""" | |
return self._raw_name | |
@raw_version.setter | |
def raw_version(self, value: str, /) -> None: | |
"""Set the ``Version`` field.""" | |
self._canonical_version = None | |
self._raw_version = value | |
@property | |
def canonical_version(self) -> Version: | |
"""Return the normalized version of ``Version``.""" | |
if self._canonical_version is None: | |
self._canonical_version = Version(self._raw_version) | |
return self._canonical_version | |
@canonical_version.setter | |
def canonical_version(self, value: Version, /) -> None: | |
"""Set the ``Version`` field to its normalized version.""" | |
self._canonical_version = value | |
self._raw_version = str(value) | |
@property | |
def raw_requires_dists(self) -> List[str]: | |
"""Return the raw ``Requires-Dist`` field data.""" | |
return self._raw_requires_dists | |
@raw_requires_dists.setter | |
def raw_requires_dists(self, value: List[str], /) -> None: | |
"""Set the ``Requires-Dist`` field.""" | |
self._canonical_requires_dists = None | |
self._raw_requires_dists = value | |
@property | |
def canonical_requires_dists(self) -> List[Requirement]: | |
"""Return the normalized version of ``Requires-Dist``.""" | |
return list(map(Requirement, self._raw_requires_dists)) | |
@canonical_requires_dists.setter | |
def canonical_requires_dists(self, value: List[Requirement], /) -> None: | |
"""Return the normalized value of ``Requires-Dist``.""" | |
self._canonical_requires_dists = value | |
self._raw_requires_dists = list(map(str, value)) | |
@property | |
def raw_requires_python(self) -> str: | |
"""Return the unprocessed value for ``Requires-Python``.""" | |
return self._raw_requires_python | |
@raw_requires_python.setter | |
def raw_requires_python(self, value: str, /) -> None: | |
"""Sets the unprocessed value for ``Requires-Python``.""" | |
self._canonical_requires_python = None | |
self._raw_requires_python = value | |
@property | |
def canonical_requires_python(self) -> SpecifierSet: | |
"""Returns the normalized value for ``Requires-Python``.""" | |
if self._canonical_requires_python is None: | |
self._canonical_requires_python = SpecifierSet(self._raw_requires_python) | |
return self._canonical_requires_python | |
@canonical_requires_python.setter | |
def canonical_requires_python(self, value: SpecifierSet, /) -> None: | |
"""Sets the normalized and unprocessed values of ``Requires-Python``.""" | |
self._canonical_requires_python = value | |
self._raw_requires_python = str(value) | |
@property | |
def raw_provides_extras(self) -> List[str]: | |
"""Returns the unprocessed value of ``Provides-Extra``.""" | |
return self._raw_provides_extras | |
@raw_provides_extras.setter | |
def raw_provides_extras(self, value: List[str], /) -> None: | |
"""Set the unprocessed value of ``Provides-Extra``.""" | |
self._canonical_provides_extras = None | |
self._raw_provides_extras = value | |
@property | |
def canonical_provides_extras(self) -> List[NormalizedName]: | |
"""Returns the normalized value of ``Provides-Extra``.""" | |
# XXX Warning via PEP 685 | |
if self._canonical_provides_extras is None: | |
self._canonical_provides_extras = list( | |
map(canonicalize_name, self._raw_provides_extras) | |
) | |
return self._canonical_provides_extras | |
@canonical_provides_extras.setter | |
def canonical_provides_extras(self, value: List[NormalizedName], /) -> None: | |
"""Sets the unprocessed and normalized values of ``Provides-Extra``.""" | |
# XXX Warning via PEP 685 | |
self._raw_provides_extras = list(value) | |
self._canonical_provides_extras = value | |
@property | |
def raw_dynamic(self) -> List[str]: | |
"""Returns unprocessed ``Dynamic`` field.""" | |
return self._raw_dynamic | |
@raw_dynamic.setter | |
def raw_dynamic(self, value: List[str], /) -> None: | |
"""Sets the unprocessed ``Dynamic`` field.""" | |
self._raw_dynamic = value | |
self._canonical_dynamic = None | |
@property | |
def canonical_dynamic(self) -> List[str]: | |
"""Returns the normalized value of ``Dynamic``.""" | |
if self._canonical_dynamic is None: | |
# XXX check values are valid | |
self._canonical_dynamic = list(map(str.lower, self._raw_dynamic)) | |
return self._canonical_dynamic | |
@canonical_dynamic.setter | |
def canonical_dynamic(self, value: List[str], /) -> None: | |
"""Sets the unprocessed and normalized values of ``Dynamic``.""" | |
self._raw_dynamic = list(value) | |
self._canonical_dynamic = value | |
@classmethod | |
def from_pyproject(cls, data: Dict[str, Any], /) -> "Metadata": | |
"""Create an instance from the dict created by parsing a pyproject.toml file.""" | |
project = data["project"] | |
kwargs = { | |
"name": project["name"], | |
"version": project["version"], | |
"description": project.get("description"), | |
"keywords": ", ".join(project.get("keywords", [])), | |
"requires_python": project.get("requires-python"), | |
"classifiers": project.get("classifiers"), | |
"dynamic": project.get("dynamic"), | |
"project_urls": list(map(", ".join, project.get("urls", []))), | |
"requires_dists": project.get("dependencies", []), | |
} | |
authors = [] | |
author_emails = [] | |
for author_details in project.get("authors", []): | |
match author_details: | |
case {"name": name, "email": email}: | |
author_emails.append(f"{name} <{email}>") | |
case {"name": name}: | |
authors.append(name) | |
case {"email": email}: | |
author_emails.append(email) | |
case _: | |
# XXX exception | |
pass | |
kwargs["author"] = ", ".join(authors) | |
kwargs["author_email"] = ", ".join(author_emails) | |
maintainers = [] | |
maintainer_emails = [] | |
for maintainer_details in project.get("maintainers", []): | |
match maintainer_details: | |
case {"name": name, "email": email}: | |
maintainer_emails.append(f"{name} <{email}>") | |
case {"name": name}: | |
maintainers.append(name) | |
case {"email": email}: | |
maintainer_emails.append(email) | |
case _: | |
# XXX exception | |
pass | |
kwargs["maintainer"] = ", ".join(maintainers) | |
kwargs["maintainer_email"] = ", ".join(maintainer_emails) | |
extras = kwargs["provides_extras"] = [] | |
all_deps = kwargs["requires_dists"] | |
for extra, deps in project.get("optional-dependencies", {}).items(): | |
extras.append(extra) | |
all_deps.extend(f"{dep}; extra == {extra!r}" for dep in deps) | |
match project.get("license"): | |
case None: | |
pass | |
case {"text": _, "file": _}: | |
# XXX exception | |
pass | |
case {"text": text}: | |
kwargs["license"] = text | |
case {"file": path}: | |
# XXX decide what to do about relative file paths from `pyproject.toml`. | |
pass | |
case _: | |
# XXX raise exception | |
pass | |
readme_details = project.get("readme") | |
match readme_details: | |
case None: | |
pass | |
case {"file": _, "text": _}: | |
# XXX exception | |
pass | |
case str(path): | |
# XXX decide what to do about relative file paths from `pyproject.toml`. | |
# XXX infer content-type | |
pass | |
case {"file": path, "content-type": content_type}: | |
# XXX decide what to do about relative file paths from `pyproject.toml`. | |
# XXX error-check content-type | |
pass | |
case {"text": text, "content-type": content_type}: | |
# XXX error-check content-type | |
kwargs["description"] = text | |
kwargs["description_content_type"] = content_type | |
return cls(**kwargs) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment