Skip to content

Instantly share code, notes, and snippets.

@brettcannon
Last active October 18, 2022 03:13
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/67dc464e1c838ca9dc5aa368168dae90 to your computer and use it in GitHub Desktop.
Save brettcannon/67dc464e1c838ca9dc5aa368168dae90 to your computer and use it in GitHub Desktop.
`RawMetadata` with caching functions to access normalized values
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