Skip to content

Commit a880907

Browse files
committed
feat: support writing metadata
Signed-off-by: Henry Schreiner <[email protected]>
1 parent 029f415 commit a880907

File tree

2 files changed

+273
-0
lines changed

2 files changed

+273
-0
lines changed

src/packaging/metadata.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import builtins
4+
import dataclasses
45
import email.feedparser
56
import email.header
67
import email.message
@@ -279,6 +280,72 @@ def _get_payload(msg: email.message.Message, source: bytes | str) -> str:
279280
"version": "version",
280281
}
281282
_RAW_TO_EMAIL_MAPPING = {raw: email for email, raw in _EMAIL_TO_RAW_MAPPING.items()}
283+
_MULTI_FIELDS = {_RAW_TO_EMAIL_MAPPING[x] for x in _LIST_FIELDS | _DICT_FIELDS}
284+
285+
286+
@dataclasses.dataclass
287+
class _JSonMessageSetter:
288+
"""
289+
This provides an API to build a JSON message output in the same way as the
290+
classic Message. Line breaks are preserved this way.
291+
"""
292+
293+
data: dict[str, str | list[str]]
294+
295+
def __setitem__(self, name: str, value: str | None) -> None:
296+
key = name.replace("-", "_")
297+
if value is None:
298+
return
299+
300+
if name == "keywords":
301+
values = (x.strip() for x in value.split(","))
302+
self.data[key] = [x for x in values if x]
303+
elif name in _MULTI_FIELDS:
304+
entry = self.data.setdefault(key, [])
305+
assert isinstance(entry, list)
306+
entry.append(value)
307+
else:
308+
self.data[key] = value
309+
310+
def set_payload(self, payload: str) -> None:
311+
self["description"] = payload
312+
313+
314+
# This class is for writing RFC822 messages
315+
class RFC822Policy(email.policy.EmailPolicy):
316+
"""
317+
This is :class:`email.policy.EmailPolicy`, but with a simple ``header_store_parse``
318+
implementation that handles multiline values, and some nice defaults.
319+
"""
320+
321+
utf8 = True
322+
mangle_from_ = False
323+
max_line_length = 0
324+
325+
def header_store_parse(self, name: str, value: str) -> tuple[str, str]:
326+
size = len(name) + 2
327+
value = value.replace("\n", "\n" + " " * size)
328+
return (name, value)
329+
330+
331+
# This class is for writing RFC822 messages
332+
class RFC822Message(email.message.EmailMessage):
333+
"""
334+
This is :class:`email.message.EmailMessage` with two small changes: it defaults to
335+
our `RFC822Policy`, and it correctly writes unicode when being called
336+
with `bytes()`.
337+
"""
338+
339+
def __init__(self) -> None:
340+
super().__init__(policy=RFC822Policy())
341+
342+
def as_bytes(
343+
self, unixfrom: bool = False, policy: email.policy.Policy | None = None
344+
) -> bytes:
345+
"""
346+
This handles unicode encoding.
347+
"""
348+
return self.as_string(unixfrom, policy=policy).encode("utf-8")
282349

283350

284351
def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:
@@ -859,3 +926,44 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata:
859926
"""``Provides`` (deprecated)"""
860927
obsoletes: _Validator[list[str] | None] = _Validator(added="1.1")
861928
"""``Obsoletes`` (deprecated)"""
929+
930+
def as_rfc822(self) -> RFC822Message:
931+
"""
932+
Return an RFC822 message with the metadata.
933+
"""
934+
message = RFC822Message()
935+
self._write_metadata(message)
936+
return message
937+
938+
def as_json(self) -> dict[str, str | list[str]]:
939+
"""
940+
Return a JSON message with the metadata.
941+
"""
942+
message: dict[str, str | list[str]] = {}
943+
smart_message = _JSonMessageSetter(message)
944+
self._write_metadata(smart_message)
945+
return message
946+
947+
def _write_metadata(self, message: RFC822Message | _JSonMessageSetter) -> None:
948+
"""
949+
Return an RFC822 message with the metadata.
950+
"""
951+
for name, validator in self.__class__.__dict__.items():
952+
if isinstance(validator, _Validator) and name != "description":
953+
value = getattr(self, name)
954+
email_name = _RAW_TO_EMAIL_MAPPING[name]
955+
if value is not None:
956+
if email_name == "project-url":
957+
for label, url in value.items():
958+
message[email_name] = f"{label}, {url}"
959+
elif email_name == "keywords":
960+
message[email_name] = ",".join(value)
961+
elif isinstance(value, list):
962+
for item in value:
963+
message[email_name] = str(item)
964+
else:
965+
message[email_name] = str(value)
966+
967+
# The description is a special case because it is in the body of the message.
968+
if self.description is not None:
969+
message.set_payload(self.description)

tests/test_metadata.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,3 +763,168 @@ def test_invalid_license_files(self, license_files):
763763

764764
with pytest.raises(metadata.InvalidMetadata):
765765
meta.license_files # noqa: B018
766+
767+
class TestMetadataWriting:
768+
def test_write_metadata(self):
769+
meta = metadata.Metadata.from_raw(_RAW_EXAMPLE)
770+
written = meta.as_rfc822().as_string()
771+
assert (
772+
written == "metadata-version: 2.3\nname: packaging\nversion: 2023.0.0\n\n"
773+
)
774+
775+
def test_write_metadata_with_description(self):
776+
# Intentionally out of order to make sure it is written in order
777+
meta = metadata.Metadata.from_raw(
778+
{
779+
"version": "1.2.3",
780+
"name": "Hello",
781+
"description": "Hello\n\nWorld👋",
782+
"metadata_version": "2.3",
783+
}
784+
)
785+
written = meta.as_rfc822().as_string()
786+
assert (
787+
written == "metadata-version: 2.3\nname: Hello\n"
788+
"version: 1.2.3\n\nHello\n\nWorld👋"
789+
)
790+
written = meta.as_rfc822().as_bytes()
791+
assert (
792+
written
793+
== "metadata-version: 2.3\nname: Hello\n"
794+
"version: 1.2.3\n\nHello\n\nWorld👋".encode()
795+
)
796+
797+
def test_multiline_license(self):
798+
meta = metadata.Metadata.from_raw(
799+
{
800+
"version": "1.2.3",
801+
"name": "packaging",
802+
"license": "Hello\nWorld🐍",
803+
"metadata_version": "2.3",
804+
}
805+
)
806+
written = meta.as_rfc822().as_string()
807+
assert (
808+
written == "metadata-version: 2.3\nname: packaging\nversion: 1.2.3"
809+
"\nlicense: Hello\n World🐍\n\n"
810+
)
811+
written = meta.as_rfc822().as_bytes()
812+
assert (
813+
written
814+
== "metadata-version: 2.3\nname: packaging\nversion: 1.2.3"
815+
"\nlicense: Hello\n World🐍\n\n".encode()
816+
)
817+
818+
def test_large(self):
819+
meta = metadata.Metadata.from_raw(
820+
{
821+
"author": "Example!",
822+
"author_email": "Unknown <[email protected]>",
823+
"classifiers": [
824+
"Development Status :: 4 - Beta",
825+
"Programming Language :: Python",
826+
],
827+
"description": "some readme 👋\n",
828+
"description_content_type": "text/markdown",
829+
"keywords": ["trampolim", "is", "interesting"],
830+
"license": "some license text",
831+
"maintainer_email": "Other Example <[email protected]>",
832+
"metadata_version": "2.1",
833+
"name": "full_metadata",
834+
"project_urls": {
835+
"homepage": "example.com",
836+
"documentation": "readthedocs.org",
837+
"repository": "github.com/some/repo",
838+
"changelog": "github.com/some/repo/blob/master/CHANGELOG.rst",
839+
},
840+
"provides_extra": ["test"],
841+
"requires_dist": [
842+
"dependency1",
843+
"dependency2>1.0.0",
844+
"dependency3[extra]",
845+
'dependency4; os_name != "nt"',
846+
'dependency5[other-extra]>1.0; os_name == "nt"',
847+
'test_dependency; extra == "test"',
848+
'test_dependency[test_extra]; extra == "test"',
849+
"test_dependency[test_extra2]>3.0; "
850+
'os_name == "nt" and extra == "test"',
851+
],
852+
"requires_python": ">=3.8",
853+
"summary": "A package with all the metadata :)",
854+
"version": "3.2.1",
855+
}
856+
)
857+
858+
assert meta.as_json() == {
859+
"author": "Example!",
860+
"author_email": "Unknown <[email protected]>",
861+
"classifier": [
862+
"Development Status :: 4 - Beta",
863+
"Programming Language :: Python",
864+
],
865+
"description": "some readme 👋\n",
866+
"description_content_type": "text/markdown",
867+
"keywords": ["trampolim", "is", "interesting"],
868+
"license": "some license text",
869+
"maintainer_email": "Other Example <[email protected]>",
870+
"metadata_version": "2.1",
871+
"name": "full_metadata",
872+
"project_url": [
873+
"homepage, example.com",
874+
"documentation, readthedocs.org",
875+
"repository, github.com/some/repo",
876+
"changelog, github.com/some/repo/blob/master/CHANGELOG.rst",
877+
],
878+
"provides_extra": ["test"],
879+
"requires_dist": [
880+
"dependency1",
881+
"dependency2>1.0.0",
882+
"dependency3[extra]",
883+
'dependency4; os_name != "nt"',
884+
'dependency5[other-extra]>1.0; os_name == "nt"',
885+
'test_dependency; extra == "test"',
886+
'test_dependency[test_extra]; extra == "test"',
887+
'test_dependency[test_extra2]>3.0; os_name == "nt" and extra == "test"',
888+
],
889+
"requires_python": ">=3.8",
890+
"summary": "A package with all the metadata :)",
891+
"version": "3.2.1",
892+
}
893+
894+
core_metadata = meta.as_rfc822()
895+
assert core_metadata.items() == [
896+
("metadata-version", "2.1"),
897+
("name", "full_metadata"),
898+
("version", "3.2.1"),
899+
("summary", "A package with all the metadata :)"),
900+
("description-content-type", "text/markdown"),
901+
("keywords", "trampolim,is,interesting"),
902+
("author", "Example!"),
903+
("author-email", "Unknown <[email protected]>"),
904+
("maintainer-email", "Other Example <[email protected]>"),
905+
("license", "some license text"),
906+
("classifier", "Development Status :: 4 - Beta"),
907+
("classifier", "Programming Language :: Python"),
908+
("requires-dist", "dependency1"),
909+
("requires-dist", "dependency2>1.0.0"),
910+
("requires-dist", "dependency3[extra]"),
911+
("requires-dist", 'dependency4; os_name != "nt"'),
912+
("requires-dist", 'dependency5[other-extra]>1.0; os_name == "nt"'),
913+
("requires-dist", 'test_dependency; extra == "test"'),
914+
("requires-dist", 'test_dependency[test_extra]; extra == "test"'),
915+
(
916+
"requires-dist",
917+
'test_dependency[test_extra2]>3.0; os_name == "nt" and extra == "test"',
918+
),
919+
("requires-python", ">=3.8"),
920+
("project-url", "homepage, example.com"),
921+
("project-url", "documentation, readthedocs.org"),
922+
("project-url", "repository, github.com/some/repo"),
923+
(
924+
"project-url",
925+
"changelog, github.com/some/repo/blob/master/CHANGELOG.rst",
926+
),
927+
("provides-extra", "test"),
928+
]
929+
930+
assert core_metadata.get_payload() == "some readme 👋\n"

0 commit comments

Comments
 (0)