Skip to content

Commit 495003b

Browse files
authored
feat: PEP 794 support (METADATA 2.5) (#260)
* feat: PEP 794 support Signed-off-by: Henry Schreiner <[email protected]> * fix: validate names Signed-off-by: Henry Schreiner <[email protected]> * tests: add some tests for import-name* Signed-off-by: Henry Schreiner <[email protected]> * fix: mypy noticed set/frozenset missing Signed-off-by: Henry Schreiner <[email protected]> * tests: add more test cases Signed-off-by: Henry Schreiner <[email protected]> * fix: support empty name Signed-off-by: Henry Schreiner <[email protected]> --------- Signed-off-by: Henry Schreiner <[email protected]>
1 parent 8233525 commit 495003b

File tree

9 files changed

+331
-4
lines changed

9 files changed

+331
-4
lines changed

pyproject_metadata/__init__.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
import email.message
3838
import email.policy
3939
import email.utils
40+
import itertools
41+
import keyword
4042
import os
4143
import os.path
4244
import pathlib
@@ -50,7 +52,7 @@
5052
from .pyproject import License, PyProjectReader, Readme
5153

5254
if typing.TYPE_CHECKING:
53-
from collections.abc import Mapping
55+
from collections.abc import Generator, Mapping
5456
from typing import Any
5557

5658
from packaging.requirements import Requirement
@@ -137,7 +139,7 @@ class _SmartMessageSetter:
137139
message: email.message.Message
138140

139141
def __setitem__(self, name: str, value: str | None) -> None:
140-
if not value:
142+
if value is None:
141143
return
142144
self.message[name] = value
143145

@@ -225,6 +227,46 @@ def _fold(
225227
return name + ": " + self.linesep.join(lines) + self.linesep # type: ignore[arg-type]
226228

227229

230+
def _validate_import_names(
231+
names: list[str], key: str, *, errors: ErrorCollector
232+
) -> Generator[str, None, None]:
233+
"""
234+
Returns normalized names for comparisons.
235+
"""
236+
for fullname in names:
237+
name, simicolon, private = fullname.partition(";")
238+
if simicolon and private.lstrip() != "private":
239+
msg = "{key} contains an ending tag other than '; private', got {value!r}"
240+
errors.config_error(msg, key=key, value=fullname)
241+
name = name.rstrip()
242+
243+
for ident in name.split("."):
244+
if not ident.isidentifier():
245+
msg = "{key} contains {value!r}, which is not a valid identifier"
246+
errors.config_error(msg, key=key, value=fullname)
247+
248+
elif keyword.iskeyword(ident):
249+
msg = "{key} contains a Python keyword, which is not a valid import name, got {value!r}"
250+
errors.config_error(msg, key=key, value=fullname)
251+
252+
yield name
253+
254+
255+
def _validate_dotted_names(names: set[str], *, errors: ErrorCollector) -> None:
256+
"""
257+
Checks to make sure every name is accounted for. Takes the union of de-tagged names.
258+
"""
259+
260+
for name in names:
261+
for parent in itertools.accumulate(
262+
name.split(".")[:-1], lambda a, b: f"{a}.{b}"
263+
):
264+
if parent not in names:
265+
msg = "{key} is missing {value!r}, but submodules are present elsewhere"
266+
errors.config_error(msg, key="project.import-namespaces", value=parent)
267+
continue
268+
269+
228270
class RFC822Message(email.message.EmailMessage):
229271
"""
230272
This is :class:`email.message.EmailMessage` with two small changes: it defaults to
@@ -271,6 +313,8 @@ class StandardMetadata:
271313
keywords: list[str] = dataclasses.field(default_factory=list)
272314
scripts: dict[str, str] = dataclasses.field(default_factory=dict)
273315
gui_scripts: dict[str, str] = dataclasses.field(default_factory=dict)
316+
import_names: list[str] | None = None
317+
import_namespaces: list[str] | None = None
274318
dynamic: list[Dynamic] = dataclasses.field(default_factory=list)
275319
"""
276320
This field is used to track dynamic fields. You can't set a field not in this list.
@@ -301,6 +345,8 @@ def auto_metadata_version(self) -> str:
301345
if self.metadata_version is not None:
302346
return self.metadata_version
303347

348+
if self.import_names is not None or self.import_namespaces is not None:
349+
return "2.5"
304350
if isinstance(self.license, str) or self.license_files is not None:
305351
return "2.4"
306352
if self.dynamic_metadata:
@@ -460,6 +506,12 @@ def from_pyproject( # noqa: C901
460506
project.get("gui-scripts", {}), "project.gui-scripts"
461507
)
462508
or {},
509+
import_names=pyproject.ensure_list(
510+
project.get("import-names", None), "project.import-names"
511+
),
512+
import_namespaces=pyproject.ensure_list(
513+
project.get("import-namespaces", None), "project.import-namespaces"
514+
),
463515
dynamic=dynamic,
464516
dynamic_metadata=dynamic_metadata or [],
465517
metadata_version=metadata_version,
@@ -504,6 +556,9 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901
504556
- ``license`` is an SPDX license expression if metadata_version >= 2.4
505557
- ``license_files`` is supported only for metadata_version >= 2.4
506558
- ``project_url`` can't contain keys over 32 characters
559+
- ``import-name(paces)s`` is only supported on metadata_version >= 2.5
560+
- ``import-name(space)s`` must be valid names, optionally with ``; private``
561+
- ``import-names`` and ``import-namespaces`` cannot overlap
507562
"""
508563
errors = ErrorCollector(collect_errors=self.all_errors)
509564

@@ -570,6 +625,37 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901
570625
msg = "{key} names cannot be more than 32 characters long"
571626
errors.config_error(msg, key="project.urls", got=name)
572627

628+
if (
629+
self.import_names is not None
630+
and self.auto_metadata_version in constants.PRE_2_5_METADATA_VERSIONS
631+
):
632+
msg = "{key} is only supported when emitting metadata version >= 2.5"
633+
errors.config_error(msg, key="project.import-names")
634+
635+
if (
636+
self.import_namespaces is not None
637+
and self.auto_metadata_version in constants.PRE_2_5_METADATA_VERSIONS
638+
):
639+
msg = "{key} is only supported when emitting metadata version >= 2.5"
640+
errors.config_error(msg, key="project.import-namespaces")
641+
642+
import_names = set(
643+
_validate_import_names(
644+
self.import_names or [], "import-names", errors=errors
645+
)
646+
)
647+
import_namespaces = set(
648+
_validate_import_names(
649+
self.import_namespaces or [], "import-namespaces", errors=errors
650+
)
651+
)
652+
in_both = import_names & import_namespaces
653+
if in_both:
654+
msg = "{key} overlaps with 'project.import-namespaces': {in_both}"
655+
errors.config_error(msg, key="project.import-names", in_both=in_both)
656+
657+
_validate_dotted_names(import_names | import_namespaces, errors=errors)
658+
573659
errors.finalize("Metadata validation failed")
574660

575661
def _write_metadata( # noqa: C901
@@ -637,6 +723,13 @@ def _write_metadata( # noqa: C901
637723
if self.readme.content_type:
638724
smart_message["Description-Content-Type"] = self.readme.content_type
639725
smart_message.set_payload(self.readme.text)
726+
for import_name in self.import_names or []:
727+
smart_message["Import-Name"] = import_name
728+
for import_namespace in self.import_namespaces or []:
729+
smart_message["Import-Namespace"] = import_namespace
730+
# Special case for empty import-names
731+
if self.import_names is not None and not self.import_names:
732+
smart_message["Import-Name"] = ""
640733
# Core Metadata 2.2
641734
if self.auto_metadata_version != "2.1":
642735
for field in self.dynamic_metadata:

pyproject_metadata/constants.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ def __dir__() -> list[str]:
2424
return __all__
2525

2626

27-
KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"}
27+
KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4", "2.5"}
2828
PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"}
29+
PRE_2_5_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"}
2930

3031
PROJECT_TO_METADATA = {
3132
"authors": frozenset(["Author", "Author-Email"]),
@@ -46,6 +47,8 @@ def __dir__() -> list[str]:
4647
"scripts": frozenset(),
4748
"urls": frozenset(["Project-URL"]),
4849
"version": frozenset(["Version"]),
50+
"import-names": frozenset(["Import-Name"]),
51+
"import-namespaces": frozenset(["Import-Namespaces"]),
4952
}
5053

5154
KNOWN_TOPLEVEL_FIELDS = {"build-system", "project", "tool", "dependency-groups"}
@@ -83,6 +86,8 @@ def __dir__() -> list[str]:
8386
"summary",
8487
"supported-platform", # Not specified via pyproject standards
8588
"version", # Can't be in dynamic
89+
"import-name",
90+
"import-namespace",
8691
}
8792

8893
KNOWN_MULTIUSE = {
@@ -100,4 +105,6 @@ def __dir__() -> list[str]:
100105
"requires", # Deprecated
101106
"obsoletes", # Deprecated
102107
"provides", # Deprecated
108+
"import-name",
109+
"import-namespace",
103110
}

pyproject_metadata/project_table.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ class LicenseTable(TypedDict, total=False):
6868
"scripts",
6969
"urls",
7070
"version",
71+
"import-names",
72+
"import-namespaces",
7173
]
7274

7375
ProjectTable = TypedDict(
@@ -90,6 +92,8 @@ class LicenseTable(TypedDict, total=False):
9092
"keywords": List[str],
9193
"scripts": Dict[str, str],
9294
"gui-scripts": Dict[str, str],
95+
"import-names": List[str],
96+
"import-namespaces": List[str],
9397
"dynamic": List[Dynamic],
9498
},
9599
total=False,

pyproject_metadata/pyproject.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,10 @@ def ensure_str(self, value: str, key: str) -> str | None:
8181
self.config_error(msg, key=key, got_type=type(value))
8282
return None
8383

84-
def ensure_list(self, val: list[T], key: str) -> list[T] | None:
84+
def ensure_list(self, val: list[T] | None, key: str) -> list[T] | None:
8585
"""Ensure that a value is a list of strings."""
86+
if val is None:
87+
return None
8688
if not isinstance(val, list):
8789
msg = "Field {key} has an invalid type, expecting a list of strings"
8890
self.config_error(msg, key=key, got_type=type(val))
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Copyright © 2019 Filipe Laíns <[email protected]>
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a
4+
copy of this software and associated documentation files (the "Software"),
5+
to deal in the Software without restriction, including without limitation
6+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
7+
and/or sell copies of the Software, and to permit persons to whom the
8+
Software is furnished to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice (including the next
11+
paragraph) shall be included in all copies or substantial portions of the
12+
Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
17+
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20+
DEALINGS IN THE SOFTWARE.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
some readme 👋

tests/packages/metadata-2.5/metadata25.py

Whitespace-only changes.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
[project]
2+
name = 'metadata25'
3+
version = '3.2.1'
4+
description = 'A package with all the metadata :)'
5+
readme = 'README.md'
6+
license = "MIT"
7+
license-files = ["LICENSE"]
8+
keywords = ['trampolim', 'is', 'interesting']
9+
authors = [
10+
{ email = '[email protected]' },
11+
{ name = 'Example!' },
12+
]
13+
maintainers = [
14+
{ name = 'Other Example', email = '[email protected]' },
15+
]
16+
classifiers = [
17+
'Development Status :: 4 - Beta',
18+
'Programming Language :: Python',
19+
]
20+
21+
requires-python = '>=3.8'
22+
dependencies = [
23+
'dependency1',
24+
'dependency2>1.0.0',
25+
'dependency3[extra]',
26+
'dependency4; os_name != "nt"',
27+
'dependency5[other-extra]>1.0; os_name == "nt"',
28+
]
29+
import-names = ["metadata25"]
30+
31+
[project.optional-dependencies]
32+
test = [
33+
'test_dependency',
34+
'test_dependency[test_extra]',
35+
'test_dependency[test_extra2] > 3.0; os_name == "nt"',
36+
]
37+
38+
[project.urls]
39+
homepage = 'example.com'
40+
documentation = 'readthedocs.org'
41+
repository = 'github.com/some/repo'
42+
changelog = 'github.com/some/repo/blob/master/CHANGELOG.rst'
43+
44+
[project.scripts]
45+
full-metadata = 'full_metadata:main_cli'
46+
47+
[project.gui-scripts]
48+
full-metadata-gui = 'full_metadata:main_gui'
49+
50+
[project.entry-points.custom]
51+
full-metadata = 'full_metadata:main_custom'

0 commit comments

Comments
 (0)