Skip to content

Instantly share code, notes, and snippets.

@brettcannon
Created October 16, 2022 18:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save brettcannon/731ddd584bad01a5ee678d332a932041 to your computer and use it in GitHub Desktop.
Save brettcannon/731ddd584bad01a5ee678d332a932041 to your computer and use it in GitHub Desktop.
`packaging.metadata` with lazy data validation
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