#!/usr/bin/env python3
#
# metadata.py
"""
Parse and create ``*dist-info/METADATA`` files.
"""
#
# Copyright © 2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.
#
# stdlib
import sys
from typing import List
# 3rd party
from domdf_python_tools.paths import PathPlus
from domdf_python_tools.typing import PathLike
from domdf_python_tools.utils import divide
# this package
from dist_meta.metadata_mapping import MetadataEmitter, MetadataMapping
__all__ = ("dump", "dumps", "load", "loads", "MissingFieldError")
DELIMITER = "\n\n"
NEWLINE_MARK = '\uf8ff'
def _clean_desc(lines: List[str], wsp: str) -> List[str]:
# Adapted from inspect.cleandoc
# Licensed under the Python Software Foundation License Version 2.
# Copyright © 2001-2020 Python Software Foundation. All rights reserved.
# Copyright © 2000 BeOpen.com. All rights reserved.
# Copyright © 1995-2000 Corporation for National Research Initiatives. All rights reserved.
# Copyright © 1991-1995 Stichting Mathematisch Centrum. All rights reserved.
assert len(wsp) == 1
# Find minimum indentation of any non-blank lines after first line.
margin = sys.maxsize
include_first_line = False
for line in lines[1:]:
content = len(line.lstrip(wsp))
if content:
indent = len(line) - content
margin = min(margin, indent)
# Remove indentation.
if margin < sys.maxsize:
if lines[0][:margin] == (wsp * margin):
include_first_line = True
for i in range(not include_first_line, len(lines)):
lines[i] = lines[i][margin:]
return lines
[docs]class MissingFieldError(ValueError):
"""
Raised when a required field is missing.
"""
[docs]def loads(rawtext: str) -> MetadataMapping:
"""
Parse Python core metadata from the given string.
:param rawtext:
:returns: A mapping of the metadata fields, and the long description
"""
rawtext = rawtext.replace("\r\n", '\n')
if DELIMITER in rawtext:
rawtext, body = rawtext.split(DELIMITER, maxsplit=1)
else:
body = ''
# unfold per RFC 5322 § 2.2.3
rawtext = rawtext.replace("\n\t", f"{NEWLINE_MARK}\t").replace("\n ", f"{NEWLINE_MARK} ")
file_content: List[str] = rawtext.split('\n')
fields: MetadataMapping = MetadataMapping()
for line in file_content:
if not line:
continue
field_name, field_value = divide(line, ':')
# pylint: disable=loop-global-usage
if field_name.lower() != "description":
fields[field_name] = field_value.replace(NEWLINE_MARK, '').lstrip()
else:
# Unwrap
description_lines = field_value.split(NEWLINE_MARK)
description_lines = _clean_desc(description_lines, ' ')
description_lines = _clean_desc(description_lines, '\t')
description_lines = _clean_desc(description_lines, '|')
# pylint: enable=loop-global-usage
# pylint: disable=loop-invariant-statement
fields["Description"] = '\n'.join(description_lines).strip() + '\n'
# pylint: enable=loop-invariant-statement
if body.strip():
if "Description" in fields:
raise ValueError(
"A value was given for the 'Description' field "
"but the body of the file is not empty."
)
else:
fields["Description"] = body.strip() + '\n'
for required_field in ["Metadata-Version", "Name", "Version"]:
if required_field not in fields:
raise MissingFieldError(f"No {required_field!r} field was provided.")
return fields
[docs]def load(filename: PathLike) -> MetadataMapping:
"""
Parse Python core metadata from the given file.
:param filename:
:returns: A mapping of the metadata fields, and the long description
"""
filename = PathPlus(filename)
return loads(filename.read_text())
[docs]def dumps(fields: MetadataMapping) -> str:
"""
Construct Python core metadata from the given fields.
:param fields:
:rtype:
.. versionchanged:: 0.4.0
Added support for the License-Expression and License-File options proposed by :pep:`639`.
.. latex:clearpage::
"""
output = MetadataEmitter(fields)
if "Metadata-Version" in fields:
version = float(fields["Metadata-Version"])
output.append(f"Metadata-Version: {fields['Metadata-Version']}")
else:
raise MissingFieldError("No 'Metadata-Version' field was provided.")
if version < 2.1:
raise ValueError("'dump_metadata' only supports metadata version 2.1 and above.")
for required_field in ["Name", "Version"]:
if required_field in fields:
output.append(f"{required_field}: {fields[required_field]}")
else:
raise MissingFieldError(f"No {required_field!r} field was provided.")
if version >= 2.2:
output.add_multiple("Dynamic")
# General Meta
output.add_single("Summary")
output.add_single("Author")
output.add_single("Author-email")
output.add_single("Maintainer")
output.add_single("Maintainer-email")
output.add_single("License")
output.add_single("License-Expression")
output.add_multiple("License-File")
output.add_single("Keywords")
# URLs
output.add_single("Home-page")
output.add_single("Download-URL")
output.add_multiple("Project-URL")
# Platforms
output.add_multiple("Platform")
output.add_multiple("Supported-Platform")
output.add_multiple("Classifier")
# Requirements
output.add_single("Requires-Python")
output.add_multiple("Requires-Dist")
output.add_multiple("Provides-Extra")
output.add_multiple("Requires-External")
output.add_multiple("Provides-Dist")
output.add_multiple("Obsoletes-Dist")
# Description
output.add_single("Description-Content-Type")
if "Description" in fields:
output.add_body(fields["Description"])
return str(output)
[docs]def dump(fields: MetadataMapping, filename: PathLike) -> int:
"""
Construct Python core metadata from the given fields, and write it to ``filename``.
:param fields:
:param filename:
"""
filename = PathPlus(filename)
return filename.write_text(dumps(fields))