From ea056e5e45a18de5f132a555a78a49c08a693d9c Mon Sep 17 00:00:00 2001 From: nawaaaaaAaar <198336470+nawaaaaaAaar@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:25:17 +0000 Subject: [PATCH 1/4] Preserve explicitly empty readme and license text --- pyproject_metadata/pyproject.py | 8 ++++---- tests/test_standard_metadata.py | 30 +++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/pyproject_metadata/pyproject.py b/pyproject_metadata/pyproject.py index bdbedbd..a693363 100644 --- a/pyproject_metadata/pyproject.py +++ b/pyproject_metadata/pyproject.py @@ -127,10 +127,10 @@ def get_license( filename = _license.get("file") text = _license.get("text") - if (filename and text) or (not filename and not text): + if (filename is None) == (text is None): return None - if filename: + if filename is not None: file = project_dir.joinpath(filename) if not file.is_file(): msg = "License file not found ({filename!r})" @@ -212,14 +212,14 @@ def get_readme( # noqa: C901 if text is None: return None - if (filename and text) or (not filename and not text): + if (filename is None) == (text is None): return None if not content_type: return None else: return None - if filename: + if filename is not None: file = project_dir.joinpath(filename) if not file.is_file(): msg = "Readme file not found ({filename!r})" diff --git a/tests/test_standard_metadata.py b/tests/test_standard_metadata.py index 66bf62a..3f9eb0b 100644 --- a/tests/test_standard_metadata.py +++ b/tests/test_standard_metadata.py @@ -1430,18 +1430,42 @@ def test_as_json(monkeypatch: pytest.MonkeyPatch) -> None: } -def test_readme_text() -> None: +@pytest.mark.parametrize("text", ["onetwothree", ""]) +def test_readme_text(text: str) -> None: pyproject = pyproject_metadata.StandardMetadata.from_pyproject( { "project": { "name": "foo", "version": "1.2.3", - "readme": {"text": "onetwothree", "content-type": "text/plain"}, + "readme": {"text": text, "content-type": "text/plain"}, } } ) assert pyproject.readme - assert pyproject.readme.text == "onetwothree" + assert pyproject.readme.text == text + assert pyproject.readme.content_type == "text/plain" + assert pyproject.as_rfc822()["Description-Content-Type"] == "text/plain" + + +def test_empty_license_text() -> None: + pyproject = pyproject_metadata.StandardMetadata.from_pyproject( + {"project": {"name": "x", "version": "1.0", "license": {"text": ""}}} + ) + assert pyproject.license == pyproject_metadata.License("", file=None) + assert pyproject.as_rfc822()["License"] == "" + + +@pytest.mark.parametrize("field", ["readme", "license"]) +def test_empty_metadata_filename(field: str) -> None: + value = ( + {"file": "", "content-type": "text/plain"} + if field == "readme" + else {"file": ""} + ) + with pytest.raises(pyproject_metadata.ConfigurationError, match="file not found"): + pyproject_metadata.StandardMetadata.from_pyproject( + {"project": {"name": "x", "version": "1.0", field: value}} + ) def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None: From 4edda15a0219c4ec57b827992a17774b9ad28366 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 22 Oct 2026 11:04:34 -0400 Subject: [PATCH 2/4] fix: round-trip empty readme and reject empty content-type Write an empty Description header for an empty readme, so the RFC 822 output reads back as an empty description like as_json. Report an empty readme content-type instead of dropping the readme. Add a changelog entry. Assisted-by: ClaudeCode:claude-opus-5-5 --- docs/changelog.md | 12 ++++++++++++ pyproject_metadata/__init__.py | 6 +++++- pyproject_metadata/project_table.py | 3 +++ pyproject_metadata/pyproject.py | 2 ++ tests/test_standard_metadata.py | 30 ++++++++++++++++++++++------- 5 files changed, 45 insertions(+), 8 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 16d8f85..1b544dd 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +Fixes: + +- Keep an explicitly empty `readme.text` or `license.text`. Before, these were + dropped silently. An empty `license.text` now writes an empty `License` field. + With METADATA 2.4 and higher, it also gives the usual warning to use an SPDX + license expression. An empty readme writes an empty `Description` field, so it + reads back as an empty string. +- Error on an empty `readme.content-type`. Before, the readme was dropped + silently. + ## 0.12.1 (7-3-2026) Fixes: diff --git a/pyproject_metadata/__init__.py b/pyproject_metadata/__init__.py index 1e50ba7..753ceb1 100644 --- a/pyproject_metadata/__init__.py +++ b/pyproject_metadata/__init__.py @@ -804,7 +804,11 @@ def _write_metadata( # noqa: C901 if self.readme: assert self.readme.content_type # verified earlier smart_message["Description-Content-Type"] = self.readme.content_type - smart_message.set_payload(self.readme.text) + if self.readme.text: + smart_message.set_payload(self.readme.text) + else: + # An empty body reads back as no description + smart_message["Description"] = "" for import_name in self.import_names or []: smart_message["Import-Name"] = import_name for import_namespace in self.import_namespaces or []: diff --git a/pyproject_metadata/project_table.py b/pyproject_metadata/project_table.py index 5701a4c..893027f 100644 --- a/pyproject_metadata/project_table.py +++ b/pyproject_metadata/project_table.py @@ -241,6 +241,9 @@ def _(prefix: str, data: object, error_collector: SimpleErrorCollector) -> None: if "content-type" not in data: msg = f'Field "{prefix}" is missing required key "content-type"' error_collector.error(ConfigurationError(msg, key=prefix)) + elif data["content-type"] == "": + msg = f'Field "{prefix}.content-type" must not be empty' + error_collector.error(ConfigurationError(msg, key=f"{prefix}.content-type")) @validate_via_prefix.register(r"project\.version") diff --git a/pyproject_metadata/pyproject.py b/pyproject_metadata/pyproject.py index a693363..df77733 100644 --- a/pyproject_metadata/pyproject.py +++ b/pyproject_metadata/pyproject.py @@ -127,6 +127,7 @@ def get_license( filename = _license.get("file") text = _license.get("text") + # Reported by the project.license validator if (filename is None) == (text is None): return None @@ -212,6 +213,7 @@ def get_readme( # noqa: C901 if text is None: return None + # Reported by the project.readme validator if (filename is None) == (text is None): return None if not content_type: diff --git a/tests/test_standard_metadata.py b/tests/test_standard_metadata.py index 3f9eb0b..7dcf408 100644 --- a/tests/test_standard_metadata.py +++ b/tests/test_standard_metadata.py @@ -10,6 +10,7 @@ import textwrap import warnings +import packaging.metadata import packaging.specifiers import packaging.version import pytest @@ -308,6 +309,16 @@ def all_errors(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) 'Field "project.readme" is missing required key "content-type"', id="Missing content-type for readme (text only)", ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = { text = "...", content-type = "" } + """, + 'Field "project.readme.content-type" must not be empty', + id="Empty content-type for readme", + ), pytest.param( """ [project] @@ -1445,6 +1456,10 @@ def test_readme_text(text: str) -> None: assert pyproject.readme.text == text assert pyproject.readme.content_type == "text/plain" assert pyproject.as_rfc822()["Description-Content-Type"] == "text/plain" + assert pyproject.as_json()["description"] == text + metadata, unparsed = packaging.metadata.parse_email(bytes(pyproject.as_rfc822())) + assert not unparsed + assert metadata.get("description") == text def test_empty_license_text() -> None: @@ -1455,13 +1470,14 @@ def test_empty_license_text() -> None: assert pyproject.as_rfc822()["License"] == "" -@pytest.mark.parametrize("field", ["readme", "license"]) -def test_empty_metadata_filename(field: str) -> None: - value = ( - {"file": "", "content-type": "text/plain"} - if field == "readme" - else {"file": ""} - ) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("readme", {"file": "", "content-type": "text/plain"}), + ("license", {"file": ""}), + ], +) +def test_empty_metadata_filename(field: str, value: dict[str, str]) -> None: with pytest.raises(pyproject_metadata.ConfigurationError, match="file not found"): pyproject_metadata.StandardMetadata.from_pyproject( {"project": {"name": "x", "version": "1.0", field: value}} From c087a3bf740472d7e8ec19472bf03df32e006eb3 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 22 Oct 2026 11:23:36 -0400 Subject: [PATCH 3/4] Apply suggestion from @henryiii --- docs/changelog.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 1b544dd..16d8f85 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,17 +1,5 @@ # Changelog -## Unreleased - -Fixes: - -- Keep an explicitly empty `readme.text` or `license.text`. Before, these were - dropped silently. An empty `license.text` now writes an empty `License` field. - With METADATA 2.4 and higher, it also gives the usual warning to use an SPDX - license expression. An empty readme writes an empty `Description` field, so it - reads back as an empty string. -- Error on an empty `readme.content-type`. Before, the readme was dropped - silently. - ## 0.12.1 (7-3-2026) Fixes: From a241dc5a4f67edaf248b5ebae9c9c18f59ac36c4 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 22 Oct 2026 11:41:38 -0400 Subject: [PATCH 4/4] fix: do not write an empty Description header An empty readme file is common, and the extra header changes the output for downstream backends. Keep the earlier output with no body. Assisted-by: ClaudeCode:claude-opus-5-5 --- pyproject_metadata/__init__.py | 6 +----- tests/test_standard_metadata.py | 4 ---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/pyproject_metadata/__init__.py b/pyproject_metadata/__init__.py index 753ceb1..1e50ba7 100644 --- a/pyproject_metadata/__init__.py +++ b/pyproject_metadata/__init__.py @@ -804,11 +804,7 @@ def _write_metadata( # noqa: C901 if self.readme: assert self.readme.content_type # verified earlier smart_message["Description-Content-Type"] = self.readme.content_type - if self.readme.text: - smart_message.set_payload(self.readme.text) - else: - # An empty body reads back as no description - smart_message["Description"] = "" + smart_message.set_payload(self.readme.text) for import_name in self.import_names or []: smart_message["Import-Name"] = import_name for import_namespace in self.import_namespaces or []: diff --git a/tests/test_standard_metadata.py b/tests/test_standard_metadata.py index 7dcf408..29851be 100644 --- a/tests/test_standard_metadata.py +++ b/tests/test_standard_metadata.py @@ -10,7 +10,6 @@ import textwrap import warnings -import packaging.metadata import packaging.specifiers import packaging.version import pytest @@ -1457,9 +1456,6 @@ def test_readme_text(text: str) -> None: assert pyproject.readme.content_type == "text/plain" assert pyproject.as_rfc822()["Description-Content-Type"] == "text/plain" assert pyproject.as_json()["description"] == text - metadata, unparsed = packaging.metadata.parse_email(bytes(pyproject.as_rfc822())) - assert not unparsed - assert metadata.get("description") == text def test_empty_license_text() -> None: