Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A metadata module with a data class for core metadata #518

Merged
merged 13 commits into from Jun 17, 2022
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
brettcannon marked this conversation as resolved.
Show resolved Hide resolved
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