Skip to content

Commit

Permalink
feat: support METADATA 2.1+ json format (#168)
Browse files Browse the repository at this point in the history
Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
  • Loading branch information
henryiii committed Sep 23, 2024
1 parent f7d180f commit 64408d6
Show file tree
Hide file tree
Showing 3 changed files with 180 additions and 19 deletions.
67 changes: 56 additions & 11 deletions pyproject_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,39 @@ def __setitem__(self, name: str, value: str | None) -> None:
return
self.message[name] = value

def set_payload(self, payload: str) -> None:
self.message.set_payload(payload)


@dataclasses.dataclass
class _JSonMessageSetter:
"""
This provides an API to build a JSON message output. Line breaks are
preserved this way.
"""

data: dict[str, str | list[str]]

def __setitem__(self, name: str, value: str | None) -> None:
name = name.lower()
key = name.replace('-', '_')

if value is None:
return

if name == 'keywords':
values = (x.strip() for x in value.split(','))
self.data[key] = [x for x in values if x]
elif name in constants.KNOWN_MULTIUSE:
entry = self.data.setdefault(key, [])
assert isinstance(entry, list)
entry.append(value)
else:
self.data[key] = value

def set_payload(self, payload: str) -> None:
self['description'] = payload


class RFC822Policy(email.policy.EmailPolicy):
"""
Expand Down Expand Up @@ -365,13 +398,20 @@ def from_pyproject(

def as_rfc822(self) -> RFC822Message:
message = RFC822Message()
self.write_to_rfc822(message)
smart_message = _SmartMessageSetter(message)
self._write_metadata(smart_message)
return message

def write_to_rfc822(self, message: email.message.Message) -> None: # noqa: C901, PLR0912
self.validate(warn=False)
def as_json(self) -> dict[str, str | list[str]]:
message: dict[str, str | list[str]] = {}
smart_message = _JSonMessageSetter(message)
self._write_metadata(smart_message)
return message

smart_message = _SmartMessageSetter(message)
def _write_metadata( # noqa: PLR0912 C901
self, smart_message: _SmartMessageSetter | _JSonMessageSetter
) -> None:
self.validate(warn=False)

smart_message['Metadata-Version'] = self.auto_metadata_version
smart_message['Name'] = self.name
Expand All @@ -383,7 +423,7 @@ def write_to_rfc822(self, message: email.message.Message) -> None: # noqa: C901
# skip 'Supported-Platform'
if self.description:
smart_message['Summary'] = self.description
smart_message['Keywords'] = ','.join(self.keywords)
smart_message['Keywords'] = ','.join(self.keywords) or None
if 'homepage' in self.urls:
smart_message['Home-page'] = self.urls['homepage']
# skip 'Download-URL'
Expand Down Expand Up @@ -422,7 +462,7 @@ def write_to_rfc822(self, message: email.message.Message) -> None: # noqa: C901
if self.readme:
if self.readme.content_type:
smart_message['Description-Content-Type'] = self.readme.content_type
message.set_payload(self.readme.text)
smart_message.set_payload(self.readme.text)
# Core Metadata 2.2
if self.auto_metadata_version != '2.1':
for field in self.dynamic_metadata:
Expand All @@ -434,12 +474,17 @@ def write_to_rfc822(self, message: email.message.Message) -> None: # noqa: C901
raise ConfigurationError(msg)
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_)
def _name_list(self, people: list[tuple[str, str | None]]) -> str | None:
return ', '.join(name for name, email_ in people if not email_) or None

def _email_list(self, people: list[tuple[str, str | None]]) -> str:
return ', '.join(
email.utils.formataddr((name, _email)) for name, _email in people if _email
def _email_list(self, people: list[tuple[str, str | None]]) -> str | None:
return (
', '.join(
email.utils.formataddr((name, _email))
for name, _email in people
if _email
)
or None
)

def _build_extra_req(
Expand Down
34 changes: 26 additions & 8 deletions pyproject_metadata/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@


__all__ = [
'KNOWN_BUILD_SYSTEM_FIELDS',
'KNOWN_METADATA_FIELDS',
'KNOWN_METADATA_VERSIONS',
'KNOWN_METADATA_VERSIONS',
'KNOWN_MULTIUSE',
'KNOWN_PROJECT_FIELDS',
'KNOWN_TOPLEVEL_FIELDS',
'PRE_SPDX_METADATA_VERSIONS',
'PROJECT_TO_METADATA',
'KNOWN_TOPLEVEL_FIELDS',
'KNOWN_BUILD_SYSTEM_FIELDS',
'KNOWN_PROJECT_FIELDS',
'KNOWN_METADATA_FIELDS',
]


Expand Down Expand Up @@ -51,8 +52,8 @@ def __dir__() -> list[str]:
'classifier',
'description',
'description-content-type',
'download-url', # Not specified via pyproject standards 'dynamic', # Can't be in dynamic
'dynamic',
'download-url', # Not specified via pyproject standards
'dynamic', # Can't be in dynamic
'home-page', # Not specified via pyproject standards
'keywords',
'license',
Expand All @@ -63,11 +64,11 @@ def __dir__() -> list[str]:
'metadata-version',
'name', # Can't be in dynamic
'obsoletes', # Deprecated
'obsoletes-dist', # Rarly used
'obsoletes-dist', # Rarely used
'platform', # Not specified via pyproject standards
'project-url',
'provides', # Deprecated
'provides-dist', # Rarly used
'provides-dist', # Rarely used
'provides-extra',
'requires', # Deprecated
'requires-dist',
Expand All @@ -77,3 +78,20 @@ def __dir__() -> list[str]:
'supported-platform', # Not specified via pyproject standards
'version', # Can't be in dynamic
}

KNOWN_MULTIUSE = {
'dynamic',
'platform',
'provides-extra',
'supported-platform',
'license-file',
'classifier',
'requires-dist',
'requires-external',
'project-url',
'provides-dist',
'obsoletes-dist',
'requires', # Deprecated
'obsoletes', # Deprecated
'provides', # Deprecated
}
98 changes: 98 additions & 0 deletions tests/test_standard_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,51 @@ def test_readme_content_type_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))


def test_as_json(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(DIR / 'packages/full-metadata')

with open('pyproject.toml', 'rb') as f:
metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))
core_metadata = metadata.as_json()

assert core_metadata == {
'author': 'Example!',
'author_email': 'Unknown <example@example.com>',
'classifier': [
'Development Status :: 4 - Beta',
'Programming Language :: Python',
],
'description': 'some readme 👋\n',
'description_content_type': 'text/markdown',
'home_page': 'example.com',
'keywords': ['trampolim', 'is', 'interesting'],
'license': 'some license text',
'maintainer_email': 'Other Example <other@example.com>',
'metadata_version': '2.1',
'name': 'full_metadata',
'project_url': [
'Homepage, example.com',
'Documentation, readthedocs.org',
'Repository, github.com/some/repo',
'Changelog, github.com/some/repo/blob/master/CHANGELOG.rst',
],
'provides_extra': ['test'],
'requires_dist': [
'dependency1',
'dependency2>1.0.0',
'dependency3[extra]',
'dependency4; os_name != "nt"',
'dependency5[other-extra]>1.0; os_name == "nt"',
'test_dependency; extra == "test"',
'test_dependency[test_extra]; extra == "test"',
'test_dependency[test_extra2]>3.0; os_name == "nt" and ' 'extra == "test"',
],
'requires_python': '>=3.8',
'summary': 'A package with all the metadata :)',
'version': '3.2.1',
}


def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(DIR / 'packages/full-metadata')

Expand Down Expand Up @@ -888,6 +933,26 @@ def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None:
assert core_metadata.get_payload() == 'some readme 👋\n'


def test_as_json_spdx(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(DIR / 'packages/spdx')

with open('pyproject.toml', 'rb') as f:
metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))
core_metadata = metadata.as_json()
assert core_metadata == {
'license_expression': 'MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)',
'license_file': [
'AUTHORS.txt',
'LICENSE.md',
'LICENSE.txt',
'licenses/LICENSE.MIT',
],
'metadata_version': '2.4',
'name': 'example',
'version': '1.2.3',
}


def test_as_rfc822_spdx(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(DIR / 'packages/spdx')

Expand Down Expand Up @@ -971,6 +1036,39 @@ def test_as_rfc822_set_metadata(metadata_version: str) -> None:
assert 'Requires-Dist: some.package; extra == "do-t"' in rfc822


def test_as_json_set_metadata() -> None:
metadata = pyproject_metadata.StandardMetadata.from_pyproject(
{
'project': {
'name': 'hi',
'version': '1.2',
'optional-dependencies': {
'under_score': ['some_package'],
'da-sh': ['some-package'],
'do.t': ['some.package'],
'empty': [],
},
}
},
metadata_version='2.1',
)
assert metadata.metadata_version == '2.1'

json = metadata.as_json()

assert json == {
'metadata_version': '2.1',
'name': 'hi',
'provides_extra': ['under-score', 'da-sh', 'do-t', 'empty'],
'requires_dist': [
'some_package; extra == "under-score"',
'some-package; extra == "da-sh"',
'some.package; extra == "do-t"',
],
'version': '1.2',
}


def test_as_rfc822_set_metadata_invalid() -> None:
with pytest.raises(
pyproject_metadata.ConfigurationError,
Expand Down

0 comments on commit 64408d6

Please sign in to comment.