From 696ce91e425edb2fa21b2a0277d70b95f06cb471 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Fri, 17 Jun 2022 15:48:13 -0700 Subject: [PATCH] Add a `metadata` module with a data class for core metadata (#518) --- .pre-commit-config.yaml | 2 +- docs/index.rst | 1 + docs/metadata.rst | 86 ++++++++++++++++++++ packaging/metadata.py | 170 ++++++++++++++++++++++++++++++++++++++++ tests/test_metadata.py | 43 ++++++++++ 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 docs/metadata.rst create mode 100644 packaging/metadata.py create mode 100644 tests/test_metadata.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ce2e4a43..f0b033f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: hooks: - id: isort - - repo: https://gitlab.com/PyCQA/flake8 + - repo: https://github.com/PyCQA/flake8 rev: "3.9.2" hooks: - id: flake8 diff --git a/docs/index.rst b/docs/index.rst index aafdae83..8d72cbf0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,6 +26,7 @@ You can install packaging with ``pip``: markers requirements tags + metadata utils .. toctree:: diff --git a/docs/metadata.rst b/docs/metadata.rst new file mode 100644 index 00000000..890750e6 --- /dev/null +++ b/docs/metadata.rst @@ -0,0 +1,86 @@ +Metadata +========== + +.. currentmodule:: packaging.metadata + +A data representation for `core metadata`_. + + +Reference +--------- + +.. class:: DynamicField + + An :class:`enum.Enum` representing fields which can be listed in + the ``Dynamic`` field of `core metadata`_. Every valid field is + a name on this enum, upper-cased with any ``-`` replaced with ``_``. + Each value is the field name lower-cased (``-`` are kept). For + example, the ``Home-page`` field has a name of ``HOME_PAGE`` and a + value of ``home-page``. + + +.. class:: Metadata(name, version, *, platforms=None, summary=None, description=None, keywords=None, home_page=None, author=None, author_emails=None, license=None, supported_platforms=None, download_url=None, classifiers=None, maintainer=None, maintainer_emails=None, requires_dists=None, requires_python=None, requires_externals=None, project_urls=None, provides_dists= None, obsoletes_dists= None, description_content_type=None, provides_extras=None, dynamic_fields=None) + + 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 on instances, + except for *name* (see :attr:`display_name` and + :attr:`canonical_name`). Any parameter that accepts an + :class:`~collections.abc.Iterable` is represented as a + :class:`list` on the corresponding attribute. + + :param str name: ``Name``. + :param packaging.version.Version version: ``Version`` (note + that this is different than ``Metadata-Version``). + :param Iterable[str] platforms: ``Platform``. + :param str summary: ``Summary``. + :param str description: ``Description``. + :param Iterable[str] keywords: ``Keywords``. + :param str home_page: ``Home-Page``. + :param str author: ``Author``. + :param Iterable[tuple[str | None, str]] author_emails: ``Author-Email`` + where the two-item tuple represents the name and email of the author, + respectively. + :param str license: ``License``. + :param Iterable[str] supported_platforms: ``Supported-Platform``. + :param str download_url: ``Download-URL``. + :param Iterable[str] classifiers: ``Classifier``. + :param str maintainer: ``Maintainer``. + :param Iterable[tuple[str | None, str]] maintainer_emails: ``Maintainer-Email``, + where the two-item tuple represents the name and email of the maintainer, + respectively. + :param Iterable[packaging.requirements.Requirement] requires_dists: ``Requires-Dist``. + :param packaging.specifiers.SpecifierSet requires_python: ``Requires-Python``. + :param Iterable[str] requires_externals: ``Requires-External``. + :param tuple[str, str] project_urls: ``Project-URL``. + :param Iterable[str] provides_dists: ``Provides-Dist``. + :param Iterable[str] obsoletes_dists: ``Obsoletes-Dist``. + :param str description_content_type: ``Description-Content-Type``. + :param Iterable[packaging.utils.NormalizedName] provides_extras: ``Provides-Extra``. + :param Iterable[DynamicField] dynamic_fields: ``Dynamic``. + + Attributes not directly corresponding to a parameter are: + + .. attribute:: display_name + + The project name to be displayed to users (i.e. not normalized). + Initially set based on the *name* parameter. + Setting this attribute will also update :attr:`canonical_name`. + + .. attribute:: canonical_name + + The normalized project name as per + :func:`packaging.utils.canonicalize_name`. The attribute is + read-only and automatically calculated based on the value of + :attr:`display_name`. + + +.. _`core metadata`: https://packaging.python.org/en/latest/specifications/core-metadata/ +.. _`project metadata`: https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ +.. _`source distribution`: https://packaging.python.org/en/latest/specifications/source-distribution-format/ +.. _`binary distrubtion`: https://packaging.python.org/en/latest/specifications/binary-distribution-format/ diff --git a/packaging/metadata.py b/packaging/metadata.py new file mode 100644 index 00000000..81405feb --- /dev/null +++ b/packaging/metadata.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import enum +from collections.abc import Iterable +from typing import Optional, Tuple + +from . import ( # Alt name avoids shadowing. + requirements, + specifiers, + utils, + version as packaging_version, +) + +# Type aliases. +_NameAndEmail = Tuple[Optional[str], str] +_LabelAndURL = Tuple[str, str] + + +@enum.unique +class DynamicField(enum.Enum): + + """ + Field names for the `dynamic` field. + + All values are lower-cased for easy comparison. + """ + + # `Name`, `Version`, and `Metadata-Version` are invalid in `Dynamic`. + # 1.0 + PLATFORM = "platform" + SUMMARY = "summary" + DESCRIPTION = "description" + KEYWORDS = "keywords" + HOME_PAGE = "home-page" + AUTHOR = "author" + AUTHOR_EMAIL = "author-email" + LICENSE = "license" + # 1.1 + SUPPORTED_PLATFORM = "supported-platform" + DOWNLOAD_URL = "download-url" + CLASSIFIER = "classifier" + # 1.2 + MAINTAINER = "maintainer" + MAINTAINER_EMAIL = "maintainer-email" + REQUIRES_DIST = "requires-dist" + REQUIRES_PYTHON = "requires-python" + REQUIRES_EXTERNAL = "requires-external" + PROJECT_URL = "project-url" + PROVIDES_DIST = "provides-dist" + OBSOLETES_DIST = "obsoletes-dist" + # 2.1 + DESCRIPTION_CONTENT_TYPE = "description-content-type" + PROVIDES_EXTRA = "provides-extra" + + +class Metadata: + + """ + A representation of core metadata. + """ + + # A property named `display_name` exposes the value. + _display_name: str + # A property named `canonical_name` exposes the value. + _canonical_name: utils.NormalizedName + version: packaging_version.Version + platforms: list[str] + summary: str + description: str + keywords: list[str] + home_page: str + author: str + author_emails: list[_NameAndEmail] + license: str + supported_platforms: list[str] + download_url: str + classifiers: list[str] + maintainer: str + maintainer_emails: list[_NameAndEmail] + requires_dists: list[requirements.Requirement] + requires_python: specifiers.SpecifierSet + requires_externals: list[str] + project_urls: list[_LabelAndURL] + provides_dists: list[str] + obsoletes_dists: list[str] + description_content_type: str + provides_extras: list[utils.NormalizedName] + dynamic_fields: list[DynamicField] + + def __init__( + self, + name: str, + version: packaging_version.Version, + *, + # 1.0 + platforms: Iterable[str] | None = None, + summary: str | None = None, + description: str | None = None, + keywords: Iterable[str] | None = None, + home_page: str | None = None, + author: str | None = None, + author_emails: Iterable[_NameAndEmail] | None = None, + license: str | None = None, + # 1.1 + supported_platforms: Iterable[str] | None = None, + download_url: str | None = None, + classifiers: Iterable[str] | None = None, + # 1.2 + maintainer: str | None = None, + maintainer_emails: Iterable[_NameAndEmail] | None = None, + requires_dists: Iterable[requirements.Requirement] | None = None, + requires_python: specifiers.SpecifierSet | None = None, + requires_externals: Iterable[str] | None = None, + project_urls: Iterable[_LabelAndURL] | None = None, + provides_dists: Iterable[str] | None = None, + obsoletes_dists: Iterable[str] | None = None, + # 2.1 + description_content_type: str | None = None, + provides_extras: Iterable[utils.NormalizedName] | None = None, + # 2.2 + dynamic_fields: Iterable[DynamicField] | None = None, + ) -> None: + """ + Set all attributes on the instance. + + An argument of `None` will be converted to an appropriate, false-y value + (e.g. the empty string). + """ + self.display_name = name + self.version = version + self.platforms = list(platforms or []) + self.summary = summary or "" + self.description = description or "" + self.keywords = list(keywords or []) + self.home_page = home_page or "" + self.author = author or "" + self.author_emails = list(author_emails 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 = list(maintainer_emails or []) + self.requires_dists = list(requires_dists or []) + self.requires_python = requires_python or specifiers.SpecifierSet() + 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_fields = list(dynamic_fields or []) + + @property + def display_name(self) -> str: + return self._display_name + + @display_name.setter + def display_name(self, value: str) -> None: + """ + Set the value for self.display_name and self.canonical_name. + """ + self._display_name = value + self._canonical_name = utils.canonicalize_name(value) + + # Use functools.cached_property once Python 3.7 support is dropped. + # Value is set by self.display_name.setter to keep in sync with self.display_name. + @property + def canonical_name(self) -> utils.NormalizedName: + return self._canonical_name diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 00000000..fda418ce --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,43 @@ +import pytest + +from packaging import metadata, utils, version + + +class TestInit: + def test_defaults(self): + specified_attributes = {"display_name", "canonical_name", "version"} + metadata_ = metadata.Metadata("packaging", version.Version("2023.0.0")) + for attr in dir(metadata_): + if attr in specified_attributes or attr.startswith("_"): + continue + assert not getattr(metadata_, attr) + + +class TestNameNormalization: + + version = version.Version("1.0.0") + display_name = "A--B" + canonical_name = utils.canonicalize_name(display_name) + + def test_via_init(self): + metadata_ = metadata.Metadata(self.display_name, self.version) + + assert metadata_.display_name == self.display_name + assert metadata_.canonical_name == self.canonical_name + + def test_via_display_name_setter(self): + metadata_ = metadata.Metadata("a", self.version) + + assert metadata_.display_name == "a" + assert metadata_.canonical_name == "a" + + metadata_.display_name = self.display_name + + assert metadata_.display_name == self.display_name + assert metadata_.canonical_name == self.canonical_name + + def test_no_canonical_name_setter(self): + metadata_ = metadata.Metadata("a", self.version) + + with pytest.raises(AttributeError): + metadata_.canonical_name = "b" # type: ignore