|
37 | 37 | import email.message |
38 | 38 | import email.policy |
39 | 39 | import email.utils |
| 40 | +import itertools |
| 41 | +import keyword |
40 | 42 | import os |
41 | 43 | import os.path |
42 | 44 | import pathlib |
|
50 | 52 | from .pyproject import License, PyProjectReader, Readme |
51 | 53 |
|
52 | 54 | if typing.TYPE_CHECKING: |
53 | | - from collections.abc import Mapping |
| 55 | + from collections.abc import Generator, Mapping |
54 | 56 | from typing import Any |
55 | 57 |
|
56 | 58 | from packaging.requirements import Requirement |
@@ -137,7 +139,7 @@ class _SmartMessageSetter: |
137 | 139 | message: email.message.Message |
138 | 140 |
|
139 | 141 | def __setitem__(self, name: str, value: str | None) -> None: |
140 | | - if not value: |
| 142 | + if value is None: |
141 | 143 | return |
142 | 144 | self.message[name] = value |
143 | 145 |
|
@@ -225,6 +227,46 @@ def _fold( |
225 | 227 | return name + ": " + self.linesep.join(lines) + self.linesep # type: ignore[arg-type] |
226 | 228 |
|
227 | 229 |
|
| 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 | + |
228 | 270 | class RFC822Message(email.message.EmailMessage): |
229 | 271 | """ |
230 | 272 | This is :class:`email.message.EmailMessage` with two small changes: it defaults to |
@@ -271,6 +313,8 @@ class StandardMetadata: |
271 | 313 | keywords: list[str] = dataclasses.field(default_factory=list) |
272 | 314 | scripts: dict[str, str] = dataclasses.field(default_factory=dict) |
273 | 315 | gui_scripts: dict[str, str] = dataclasses.field(default_factory=dict) |
| 316 | + import_names: list[str] | None = None |
| 317 | + import_namespaces: list[str] | None = None |
274 | 318 | dynamic: list[Dynamic] = dataclasses.field(default_factory=list) |
275 | 319 | """ |
276 | 320 | 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: |
301 | 345 | if self.metadata_version is not None: |
302 | 346 | return self.metadata_version |
303 | 347 |
|
| 348 | + if self.import_names is not None or self.import_namespaces is not None: |
| 349 | + return "2.5" |
304 | 350 | if isinstance(self.license, str) or self.license_files is not None: |
305 | 351 | return "2.4" |
306 | 352 | if self.dynamic_metadata: |
@@ -460,6 +506,12 @@ def from_pyproject( # noqa: C901 |
460 | 506 | project.get("gui-scripts", {}), "project.gui-scripts" |
461 | 507 | ) |
462 | 508 | 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 | + ), |
463 | 515 | dynamic=dynamic, |
464 | 516 | dynamic_metadata=dynamic_metadata or [], |
465 | 517 | metadata_version=metadata_version, |
@@ -504,6 +556,9 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901 |
504 | 556 | - ``license`` is an SPDX license expression if metadata_version >= 2.4 |
505 | 557 | - ``license_files`` is supported only for metadata_version >= 2.4 |
506 | 558 | - ``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 |
507 | 562 | """ |
508 | 563 | errors = ErrorCollector(collect_errors=self.all_errors) |
509 | 564 |
|
@@ -570,6 +625,37 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901 |
570 | 625 | msg = "{key} names cannot be more than 32 characters long" |
571 | 626 | errors.config_error(msg, key="project.urls", got=name) |
572 | 627 |
|
| 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 | + |
573 | 659 | errors.finalize("Metadata validation failed") |
574 | 660 |
|
575 | 661 | def _write_metadata( # noqa: C901 |
@@ -637,6 +723,13 @@ def _write_metadata( # noqa: C901 |
637 | 723 | if self.readme.content_type: |
638 | 724 | smart_message["Description-Content-Type"] = self.readme.content_type |
639 | 725 | 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"] = "" |
640 | 733 | # Core Metadata 2.2 |
641 | 734 | if self.auto_metadata_version != "2.1": |
642 | 735 | for field in self.dynamic_metadata: |
|
0 commit comments