Last active
October 18, 2022 03:13
`RawMetadata` with caching functions to access normalized values
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 functools | |
from typing import Any, Callable, Dict, Iterable, List, Optional, TypeVar | |
from .requirements import Requirement | |
from .specifiers import SpecifierSet | |
from .utils import NormalizedName, canonicalize_name | |
from .version import Version | |
_T = TypeVar("_T") | |
class RawMetadata: | |
"""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. | |
""" | |
name: str | |
version: str | |
platforms: List[str] | |
summary: str | |
description: str | |
keywords: str | |
home_page: str | |
author: str | |
author_email: str | |
license: str | |
supported_platforms: List[str] | |
download_url: str | |
classifiers: List[str] | |
maintainer: str | |
maintainer_email: str | |
requires_dists: List[str] | |
requires_python: str | |
requires_externals: List[str] | |
project_urls: List[str] | |
provides_dists: List[str] | |
obsoletes_dists: List[str] | |
description_content_type: str | |
provides_extras: List[str] | |
dynamic: List[str] | |
def __init__( | |
self, | |
name: str, | |
# 1.0 | |
*, | |
version: Optional[str] = None, | |
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: Optional[Iterable[str]] = None, | |
requires_python: Optional[str] = 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 RawMetadata 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.name = name | |
self.version = version or "" | |
self.platforms = list(platforms or []) | |
self.summary = summary or "" | |
self.description = description or "" | |
self.keywords = keywords or "" | |
self.home_page = home_page or "" | |
self.author = author or "" | |
self.author_emails = author_email or "" | |
self.license = license or "" | |
self.supported_platforms = list(supported_platforms or []) | |
self.download_url = download_url or "" | |
self.classifiers = list(classifiers or []) | |
self.maintainer = maintainer or "" | |
self.maintainer_emails = maintainer_email or "" | |
self.requires_dists = list(requires_dists or []) | |
self.requires_python = requires_python or "" | |
self.requires_externals = list(requires_externals or []) | |
self.project_urls = list(project_urls or []) | |
self.provides_dists = list(provides_dists or []) | |
self.obsoletes_dists = list(obsoletes_dists or []) | |
self.description_content_type = description_content_type or "" | |
self.provides_extras = list(provides_extras or []) | |
self.dynamic = list(dynamic or []) | |
@classmethod | |
def from_pyproject(cls, data: Dict[str, Any], /) -> "RawMetadata": | |
"""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) | |
def _normalization_cache(func: Callable[[RawMetadata], _T]) -> Callable[[RawMetadata], _T]: | |
cache = {} | |
@functools.wraps(func) | |
def wrapper(raw: RawMetadata, /) -> _T: | |
raw_value = getattr(raw, func.__name__) | |
if not isinstance(raw_value, str): | |
key = tuple(raw_value) | |
else: | |
key = raw_value | |
if key not in cache: | |
value = func(raw) | |
cache[key] = value | |
return cache[key] | |
return wrapper | |
@_normalization_cache | |
def name(raw: RawMetadata, /) -> NormalizedName: | |
return canonicalize_name(raw.name) | |
@_normalization_cache | |
def version(raw: RawMetadata, /) -> Version: | |
return Version(raw.version) | |
@_normalization_cache | |
def requires_dists(raw: RawMetadata, /) -> List[Requirement]: | |
return list(map(Requirement, raw.requires_dists)) | |
@_normalization_cache | |
def requires_python(raw: RawMetadata, /) -> SpecifierSet: | |
return SpecifierSet(raw.requires_python) | |
@_normalization_cache | |
def provides_extras(raw: RawMetadata, /) -> List[NormalizedName]: | |
# XXX warning via PEP 685 | |
return list(map(canonicalize_name, raw.provides_extras)) | |
def replace_dynamic(raw: RawMetadata, field: str, value: Any, /) -> None: | |
# XXX Can use `@typing.overload` along with `Literal` for strong typing. | |
# XXX Map `field` to attribute name. | |
# XXX Use default values to effectively remove something from `Dynamic`. | |
pass | |
def validate(raw: RawMetadata, /) -> None: | |
# name(raw); can't fail. | |
if raw.version: | |
version(raw) | |
if raw.requires_dists: | |
requires_dists(raw) | |
if raw.requires_python: | |
requires_python(raw) | |
if raw.provides_extras: | |
provides_extras(raw) | |
if raw.dynamic: | |
# XXX Make sure `Dynamic` is valid and no field names have actual values. | |
pass |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment