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: rework how dynamic works #157

Merged
merged 4 commits into from
Sep 18, 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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pkg_info = metadata.as_rfc822()
print(str(pkg_info))) # core metadata
```

## METADATA 2.4
## SPDX licenses (METADATA 2.4+)

If `project.license` is a string or `project.license-files` is present, then
METADATA 2.4+ will be used. A user is expected to validate and normalize
Expand All @@ -51,3 +51,13 @@ folder, preserving the original source structure.


[core metadata]: https://packaging.python.org/specifications/core-metadata/


## Dynamic Metadata (METADATA 2.2+)

Pyproject-metadata supports dynamic metadata. To use it, specify your METADATA fields in `dynamic_metadata`. If you want to convert `pyproject.toml` field names to METADATA field(s), use `pyproject_metadata.pyproject_to_metadata("field-name")`, which will return a frozenset of metadata names that are touched by that field.


## Adding extra fields

You can add extra fields to the Message returned by `to_rfc822()`, as long as they are valid metadata entries.
106 changes: 77 additions & 29 deletions pyproject_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,62 @@
KNOWN_METADATA_VERSIONS = {'2.1', '2.2', '2.3', '2.4'}
PRE_SPDX_METADATA_VERSIONS = {'2.1', '2.2', '2.3'}

PROJECT_TO_METADATA = {
'authors': frozenset(['Author', 'Author-Email']),
'classifiers': frozenset(['Classifier']),
'dependencies': frozenset(['Requires-Dist']),
'description': frozenset(['Summary']),
'dynamic': frozenset(),
'entry-points': frozenset(),
'gui-scripts': frozenset(),
'keywords': frozenset(['Keywords']),
'license': frozenset(['License', 'License-Expression']),
'license-files': frozenset(['License-File']),
'maintainers': frozenset(['Maintainer', 'Maintainer-Email']),
'name': frozenset(['Name']),
'optional-dependencies': frozenset(['Provides-Extra', 'Requires-Dist']),
'readme': frozenset(['Description', 'Description-Content-Type']),
'requires-python': frozenset(['Requires-Python']),
'scripts': frozenset(),
'urls': frozenset(['Project-URL']),
'version': frozenset(['Version']),
}

KNOWN_TOPLEVEL_FIELDS = {'build-system', 'project', 'tool'}
KNOWN_BUILD_SYSTEM_FIELDS = {'backend-path', 'build-backend', 'requires'}
KNOWN_PROJECT_FIELDS = {
'authors',
'classifiers',
'dependencies',
KNOWN_PROJECT_FIELDS = set(PROJECT_TO_METADATA)

KNOWN_METADATA_FIELDS = {
'author',
'author-email',
'classifier',
'description',
'dynamic',
'entry-points',
'gui-scripts',
'description-content-type',
'download-urL', # Not specified via pyproject standards
'dynamic', # Can't be in dynamic
'home-page', # Not specified via pyproject standards
'keywords',
'license',
'license-files',
'maintainers',
'name',
'optional-dependencies',
'readme',
'license-expression',
'license-file',
'maintainer',
'maintainer-email',
'metadata-version',
'name', # Can't be in dynamic
'obsoletes', # Deprecated
'obsoletes-dist', # Rarly used
'platform', # Not specified via pyproject standards
'project-url',
'provides', # Deprecated
'provides-dist', # Rarly used
'provides-extra',
'requires', # Deprecated
'requires-dist',
'requires-external', # Not specified via pyproject standards
'requires-python',
'scripts',
'urls',
'version',
'summary',
'supported-platform', # Not specified via pyproject standards
'version', # Can't be in dynamic
}


Expand All @@ -71,6 +106,7 @@
'RFC822Policy',
'Readme',
'StandardMetadata',
'field_to_metadata',
'validate_build_system',
'validate_project',
'validate_top_level',
Expand All @@ -81,6 +117,13 @@ def __dir__() -> list[str]:
return __all__


def field_to_metadata(field: str) -> frozenset[str]:
"""
Return the METADATA fields that correspond to a project field.
"""
return frozenset(PROJECT_TO_METADATA[field])


def validate_top_level(pyproject: Mapping[str, Any]) -> None:
extra_keys = set(pyproject) - KNOWN_TOPLEVEL_FIELDS
if extra_keys:
Expand Down Expand Up @@ -147,6 +190,9 @@ class RFC822Policy(email.policy.EmailPolicy):
max_line_length = 0

def header_store_parse(self, name: str, value: str) -> tuple[str, str]:
if name.lower() not in KNOWN_METADATA_FIELDS:
msg = f'Unknown field "{name}"'
raise ConfigurationError(msg, key=name)
size = len(name) + 2
value = value.replace('\n', '\n' + ' ' * size)
return (name, value)
Expand Down Expand Up @@ -474,6 +520,13 @@ class StandardMetadata:
scripts: dict[str, str] = dataclasses.field(default_factory=dict)
gui_scripts: dict[str, str] = dataclasses.field(default_factory=dict)
dynamic: list[str] = dataclasses.field(default_factory=list)
"""
This field is used to track dynamic fields. You can't set a field not in this list.
"""
dynamic_metadata: list[str] = dataclasses.field(default_factory=list)
"""
This is a list of METADATA fields that can change inbetween SDist and wheel. Requires metadata_version 2.2+.
"""

metadata_version: str | None = None

Expand Down Expand Up @@ -541,7 +594,7 @@ def auto_metadata_version(self) -> str:

if isinstance(self.license, str) or self.license_files is not None:
return '2.4'
if self.dynamic:
if self.dynamic_metadata:
return '2.2'
return '2.1'

Expand All @@ -555,6 +608,7 @@ def from_pyproject(
data: Mapping[str, Any],
project_dir: str | os.PathLike[str] = os.path.curdir,
metadata_version: str | None = None,
dynamic_metadata: list[str] | None = None,
*,
allow_extra_keys: bool | None = None,
) -> Self:
Expand Down Expand Up @@ -626,19 +680,10 @@ def from_pyproject(
scripts=fetcher.get_dict('project.scripts'),
gui_scripts=fetcher.get_dict('project.gui-scripts'),
dynamic=dynamic,
dynamic_metadata=dynamic_metadata or [],
metadata_version=metadata_version,
)

def _update_dynamic(self, value: Any) -> None:
if value and 'version' in self.dynamic:
self.dynamic.remove('version')

def __setattr__(self, name: str, value: Any) -> None:
# update dynamic when version is set
if name == 'version' and hasattr(self, 'dynamic'):
self._update_dynamic(value)
super().__setattr__(name, value)

def as_rfc822(self) -> RFC822Message:
message = RFC822Message()
self.write_to_rfc822(message)
Expand Down Expand Up @@ -701,9 +746,12 @@ def write_to_rfc822(self, message: email.message.Message) -> None: # noqa: C901
message.set_payload(self.readme.text)
# Core Metadata 2.2
if self.auto_metadata_version != '2.1':
for field in self.dynamic:
if field in ('name', 'version'):
msg = f'Field cannot be dynamic: {field}'
for field in self.dynamic_metadata:
if field.lower() in {'name', 'version', 'dynamic'}:
msg = f'Field cannot be set as dynamic metadata: {field}'
raise ConfigurationError(msg)
if field.lower() not in KNOWN_METADATA_FIELDS:
msg = f'Field is not known: {field}'
raise ConfigurationError(msg)
smart_message['Dynamic'] = field

Expand Down
29 changes: 27 additions & 2 deletions tests/test_rfc822.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import email.message
import inspect
import re
import textwrap

import pytest
Expand Down Expand Up @@ -116,10 +117,16 @@
),
],
)
def test_headers(items: list[tuple[str, str]], data: str) -> None:
def test_headers(
items: list[tuple[str, str]], data: str, monkeypatch: pytest.MonkeyPatch
) -> None:
message = pyproject_metadata.RFC822Message()
smart_message = pyproject_metadata._SmartMessageSetter(message)

monkeypatch.setattr(
pyproject_metadata, 'KNOWN_METADATA_FIELDS', {x.lower() for x, _ in items}
)

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

Expand All @@ -132,7 +139,10 @@ def test_headers(items: list[tuple[str, str]], data: str) -> None:
]


def test_body() -> None:
def test_body(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
pyproject_metadata, 'KNOWN_METADATA_FIELDS', {'itema', 'itemb', 'itemc'}
)
message = pyproject_metadata.RFC822Message()

message['ItemA'] = 'ValueA'
Expand Down Expand Up @@ -170,6 +180,21 @@ def test_body() -> None:
assert bytes(message) == full.encode('utf-8')


def test_unknown_field() -> None:
message = pyproject_metadata.RFC822Message()
with pytest.raises(
pyproject_metadata.ConfigurationError,
match=re.escape('Unknown field "Unknown"'),
):
message['Unknown'] = 'Value'


def test_known_field() -> None:
message = pyproject_metadata.RFC822Message()
message['Platform'] = 'Value'
assert str(message) == 'Platform: Value\n\n'


def test_convert_optional_dependencies() -> None:
metadata = pyproject_metadata.StandardMetadata.from_pyproject(
{
Expand Down
30 changes: 25 additions & 5 deletions tests/test_standard_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,7 @@ def test_as_rfc822_dynamic(monkeypatch: pytest.MonkeyPatch) -> None:

with open('pyproject.toml', 'rb') as f:
metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f))
metadata.dynamic_metadata = ['description']
core_metadata = metadata.as_rfc822()
assert core_metadata.items() == [
('Metadata-Version', '2.2'),
Expand Down Expand Up @@ -971,17 +972,37 @@ def test_as_rfc822_invalid_dynamic() -> None:
metadata = pyproject_metadata.StandardMetadata(
name='something',
version=packaging.version.Version('1.0.0'),
dynamic_metadata=['name'],
)
metadata.dynamic = ['name']
with pytest.raises(
pyproject_metadata.ConfigurationError, match='Field cannot be dynamic: name'
pyproject_metadata.ConfigurationError,
match='Field cannot be set as dynamic metadata: name',
):
metadata.as_rfc822()
metadata.dynamic = ['version']
metadata.dynamic_metadata = ['version']
with pytest.raises(
pyproject_metadata.ConfigurationError, match='Field cannot be dynamic: version'
pyproject_metadata.ConfigurationError,
match='Field cannot be set as dynamic metadata: version',
):
metadata.as_rfc822()
metadata.dynamic_metadata = ['unknown']
with pytest.raises(
pyproject_metadata.ConfigurationError,
match='Field is not known: unknown',
):
metadata.as_rfc822()


def test_as_rfc822_mapped_dynamic() -> None:
metadata = pyproject_metadata.StandardMetadata(
name='something',
version=packaging.version.Version('1.0.0'),
dynamic_metadata=list(pyproject_metadata.field_to_metadata('description')),
)
assert (
str(metadata.as_rfc822())
== 'Metadata-Version: 2.2\nName: something\nVersion: 1.0.0\nDynamic: Summary\n\n'
)


def test_as_rfc822_missing_version() -> None:
Expand Down Expand Up @@ -1043,7 +1064,6 @@ def test_version_dynamic() -> None:
}
)
metadata.version = packaging.version.Version('1.2.3')
assert 'version' not in metadata.dynamic


def test_missing_keys_warns() -> None:
Expand Down