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

refactor: use EmailMessage class #142

Merged
merged 1 commit into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 44 additions & 50 deletions pyproject_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import copy
import dataclasses
import email.message
import email.policy
import email.utils
import os
import os.path
Expand Down Expand Up @@ -113,46 +115,36 @@ class ConfigurationWarning(UserWarning):
"""Warnings about backend metadata."""


class RFC822Message:
"""Python-flavored RFC 822 message implementation."""

__slots__ = ('_headers', '_body')

def __init__(self) -> None:
self._headers: list[tuple[str, str]] = []
self._body: str | None = None
@dataclasses.dataclass
class _SmartMessageSetter:
"""
This provides a nice internal API for setting values in an RFC822Message to
reduce boilerplate.

def items(self) -> list[tuple[str, str]]:
return self._headers.copy()
If a value is None, do nothing.
If a value contains a newline, indent it (may produce a warning in the future).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh? Do we actually need this? Why? Does this even work with EmailMesage? I would not be surprised if it normalized whitespace in headers or encode it somehow. Anyhow, how do multi-line strings end up here?

Copy link
Collaborator Author

@henryiii henryiii Sep 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It produces the same thing we produce now. It was added in #2, and pretty sure it mostly shows up with multiline summaries:

I think we could add a warning when one is detected, and then not include the behavior if we can make backward incompatible changes (#140).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Note that in #2, the extended mechanism is only for the description, which is now in the body instead, and the actual mechanism included pipe chars).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(and in scikit-build-core I manually disallow multiline summaries)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And you can actually read these multiline strings with email.parse, I did check it, it keeps the indention. Without indentation, they don't work, of course.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pulled out and merged the preparation steps, so you can see just the move by itself.

"""

def get_all(self, name: str) -> None | list[str]:
return [v for k, v in self.items() if k == name]
message: email.message.EmailMessage

def __setitem__(self, name: str, value: str | None) -> None:
if not value:
return
self._headers.append((name, value))
if '\n' in value:
value = value.replace('\n', '\n ')
self.message[name] = value

def __str__(self) -> str:
text = ''
for name, entry in self.items():
lines = entry.strip('\n').split('\n')
text += f'{name}: {lines[0]}\n'
for line in lines[1:]:
text += ' ' * 8 + line + '\n'
text += '\n'
if self._body:
text += self._body
return text

def __bytes__(self) -> bytes:
return str(self).encode()
class RFC822Message(email.message.EmailMessage):
"""Python-flavored RFC 822 message implementation."""

def get_payload(self) -> str | None:
return self._body
__slots__ = ()

def set_payload(self, body: str) -> None:
self._body = body
def __init__(self) -> None:
super().__init__(email.policy.compat32)

def __str__(self) -> str:
return bytes(self).decode('utf-8')


class DataFetcher:
Expand Down Expand Up @@ -567,58 +559,60 @@ def as_rfc822(self) -> RFC822Message:
self.write_to_rfc822(message)
return message

def write_to_rfc822(self, message: RFC822Message) -> None: # noqa: C901
def write_to_rfc822(self, message: email.message.EmailMessage) -> None: # noqa: C901
self.validate()

message['Metadata-Version'] = self.metadata_version
message['Name'] = self.name
smart_message = _SmartMessageSetter(message)

smart_message['Metadata-Version'] = self.metadata_version
smart_message['Name'] = self.name
if not self.version:
msg = 'Missing version field'
raise ConfigurationError(msg)
message['Version'] = str(self.version)
smart_message['Version'] = str(self.version)
# skip 'Platform'
# skip 'Supported-Platform'
if self.description:
message['Summary'] = self.description
message['Keywords'] = ','.join(self.keywords)
smart_message['Summary'] = self.description
smart_message['Keywords'] = ','.join(self.keywords)
if 'homepage' in self.urls:
message['Home-page'] = self.urls['homepage']
smart_message['Home-page'] = self.urls['homepage']
# skip 'Download-URL'
message['Author'] = self._name_list(self.authors)
message['Author-Email'] = self._email_list(self.authors)
message['Maintainer'] = self._name_list(self.maintainers)
message['Maintainer-Email'] = self._email_list(self.maintainers)
smart_message['Author'] = self._name_list(self.authors)
smart_message['Author-Email'] = self._email_list(self.authors)
smart_message['Maintainer'] = self._name_list(self.maintainers)
smart_message['Maintainer-Email'] = self._email_list(self.maintainers)
if self.license:
message['License'] = self.license.text
smart_message['License'] = self.license.text
for classifier in self.classifiers:
message['Classifier'] = classifier
smart_message['Classifier'] = classifier
# skip 'Provides-Dist'
# skip 'Obsoletes-Dist'
# skip 'Requires-External'
for name, url in self.urls.items():
message['Project-URL'] = f'{name.capitalize()}, {url}'
smart_message['Project-URL'] = f'{name.capitalize()}, {url}'
if self.requires_python:
message['Requires-Python'] = str(self.requires_python)
smart_message['Requires-Python'] = str(self.requires_python)
for dep in self.dependencies:
message['Requires-Dist'] = str(dep)
smart_message['Requires-Dist'] = str(dep)
for extra, requirements in self.optional_dependencies.items():
norm_extra = extra.replace('.', '-').replace('_', '-').lower()
message['Provides-Extra'] = norm_extra
smart_message['Provides-Extra'] = norm_extra
for requirement in requirements:
message['Requires-Dist'] = str(
smart_message['Requires-Dist'] = str(
self._build_extra_req(norm_extra, requirement)
)
if self.readme:
if self.readme.content_type:
message['Description-Content-Type'] = self.readme.content_type
smart_message['Description-Content-Type'] = self.readme.content_type
message.set_payload(self.readme.text)
# Core Metadata 2.2
if self.metadata_version != '2.1':
for field in self.dynamic:
if field in ('name', 'version'):
msg = f'Field cannot be dynamic: {field}'
raise ConfigurationError(msg)
message['Dynamic'] = field
smart_message['Dynamic'] = field

def _name_list(self, people: list[tuple[str, str | None]]) -> str:
return ', '.join(name for name, email_ in people if not email_)
Expand Down
3 changes: 2 additions & 1 deletion tests/test_rfc822.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,10 @@
)
def test_headers(items: list[tuple[str, str]], data: str) -> None:
message = pyproject_metadata.RFC822Message()
smart_message = pyproject_metadata._SmartMessageSetter(message)

for name, value in items:
message[name] = value
smart_message[name] = value

data = textwrap.dedent(data) + '\n'
assert str(message) == data
Expand Down