Skip to content

Commit

Permalink
Add a metadata module with a data class for core metadata (pypa#518)
Browse files Browse the repository at this point in the history
  • Loading branch information
brettcannon authored and hrnciar committed Jun 24, 2022
1 parent bf1c1ae commit 696ce91
Show file tree
Hide file tree
Showing 5 changed files with 301 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Expand Up @@ -26,6 +26,7 @@ You can install packaging with ``pip``:
markers
requirements
tags
metadata
utils

.. toctree::
Expand Down
86 changes: 86 additions & 0 deletions 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/
170 changes: 170 additions & 0 deletions 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
43 changes: 43 additions & 0 deletions 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

0 comments on commit 696ce91

Please sign in to comment.