99import email .message
1010import email .policy
1111import email .utils
12+ import itertools
13+ import keyword
1214import os
1315import os .path
1416import pathlib
2325
2426if typing .TYPE_CHECKING : # pragma: no cover
2527 import sys
26- from collections .abc import Mapping , Sequence
28+ from collections .abc import Generator , Mapping , Sequence
2729 from typing import Any
2830
2931 from .requirements import Requirement
5557 "dynamic" ,
5658 "entry-points" ,
5759 "gui-scripts" ,
60+ "import-names" ,
61+ "import-namespaces" ,
5862 "keywords" ,
5963 "license" ,
6064 "license-files" ,
6872 "version" ,
6973}
7074PRE_SPDX_METADATA_VERSIONS = {"2.1" , "2.2" , "2.3" }
75+ PRE_2_5_METADATA_VERSIONS = {"2.1" , "2.2" , "2.3" , "2.4" }
7176
7277
7378def extras_top_level (pyproject_table : Mapping [str , Any ]) -> set [str ]:
@@ -91,12 +96,55 @@ def extras_project(pyproject_table: Mapping[str, Any]) -> set[str]:
9196 return set (pyproject_table .get ("project" , [])) - KNOWN_PROJECT_FIELDS
9297
9398
99+ def _validate_import_names (
100+ names : list [str ], key : str , * , errors : ErrorCollector
101+ ) -> Generator [str , None , None ]:
102+ """
103+ Returns normalized names for comparisons.
104+ """
105+ for fullname in names :
106+ name , simicolon , private = fullname .partition (";" )
107+ if simicolon and private .lstrip () != "private" :
108+ msg = "{key} contains an ending tag other than '; private', got {value!r}"
109+ errors .config_error (msg , key = key , value = fullname )
110+ name = name .rstrip ()
111+
112+ for ident in name .split ("." ):
113+ if not ident .isidentifier ():
114+ msg = "{key} contains {value!r}, which is not a valid identifier"
115+ errors .config_error (msg , key = key , value = fullname )
116+
117+ elif keyword .iskeyword (ident ):
118+ msg = (
119+ "{key} contains a Python keyword,"
120+ " which is not a valid import name, got {value!r}"
121+ )
122+ errors .config_error (msg , key = key , value = fullname )
123+
124+ yield name
125+
126+
127+ def _validate_dotted_names (names : set [str ], * , errors : ErrorCollector ) -> None :
128+ """
129+ Checks to make sure every name is accounted for. Takes the union of de-tagged names.
130+ """
131+
132+ for name in names :
133+ for parent in itertools .accumulate (
134+ name .split ("." )[:- 1 ], lambda a , b : f"{ a } .{ b } "
135+ ):
136+ if parent not in names :
137+ msg = "{key} is missing {value!r}, but submodules are present elsewhere"
138+ errors .config_error (msg , key = "project.import-namespaces" , value = parent )
139+ continue
140+
141+
94142@dataclasses .dataclass
95143class StandardMetadata :
96144 """
97145 This class represents the standard metadata fields for a project. It can be
98146 used to read metadata from a pyproject.toml table, validate it, and write it
99- to an RFC822 message or JSON .
147+ to an RFC822 message.
100148 """
101149
102150 name : str
@@ -118,6 +166,8 @@ class StandardMetadata:
118166 keywords : list [str ] = dataclasses .field (default_factory = list )
119167 scripts : dict [str , str ] = dataclasses .field (default_factory = dict )
120168 gui_scripts : dict [str , str ] = dataclasses .field (default_factory = dict )
169+ import_names : list [str ] | None = None
170+ import_namespaces : list [str ] | None = None
121171 dynamic : list [Dynamic ] = dataclasses .field (default_factory = list )
122172 """
123173 This field is used to track dynamic fields. You can't set a field not in this list.
@@ -273,14 +323,31 @@ def from_pyproject(
273323 project .get ("gui-scripts" , {}), "project.gui-scripts"
274324 )
275325 or {},
326+ import_names = pyproject .ensure_list (
327+ project .get ("import-names" , None ), "project.import-names"
328+ ),
329+ import_namespaces = pyproject .ensure_list (
330+ project .get ("import-namespaces" , None ), "project.import-namespaces"
331+ ),
276332 dynamic = dynamic ,
277333 )
278334
279335 pyproject .finalize ("Failed to parse pyproject.toml" )
280336 assert self is not None
281337 return self
282338
283- def validate_metdata (self , metadata_version : str ) -> None :
339+ def validate_metadata (self , metadata_version : str ) -> None :
340+ """
341+ Validate metadata for consistency and correctness given a metadata
342+ version. This is called when making metadata. Checks:
343+
344+ - ``license`` is not an SPDX license expression if metadata_version
345+ >= 2.4 (warning)
346+ - License classifiers deprecated for metadata_version >= 2.4 (warning)
347+ - ``license`` is an SPDX license expression if metadata_version >= 2.4
348+ - ``license_files`` is supported only for metadata_version >= 2.4
349+ - ``import-name(paces)s`` is only supported on metadata_version >= 2.5
350+ """
284351 errors = ErrorCollector ()
285352
286353 if not self .version :
@@ -317,28 +384,37 @@ def validate_metdata(self, metadata_version: str) -> None:
317384 self .license_files is not None
318385 and metadata_version in PRE_SPDX_METADATA_VERSIONS
319386 ):
320- msg = "{key} is supported only when emitting metadata version >= 2.4"
387+ msg = "{key} is only supported when emitting metadata version >= 2.4"
321388 errors .config_error (msg , key = "project.license-files" )
322389
390+ if (
391+ self .import_names is not None
392+ and metadata_version in PRE_2_5_METADATA_VERSIONS
393+ ):
394+ msg = "{key} is only supported when emitting metadata version >= 2.5"
395+ errors .config_error (msg , key = "project.import-names" )
396+
397+ if (
398+ self .import_namespaces is not None
399+ and metadata_version in PRE_2_5_METADATA_VERSIONS
400+ ):
401+ msg = "{key} is only supported when emitting metadata version >= 2.5"
402+ errors .config_error (msg , key = "project.import-namespaces" )
403+
323404 errors .finalize ("Metadata validation failed" )
324405
325406 def validate (self ) -> None :
326407 """
327- Validate metadata for consistency and correctness. Will also produce
328- warnings if ``warn`` is given. Respects ``all_errors``. This is called
329- when loading a pyproject.toml, and when making metadata. Checks:
408+ Validate metadata for consistency and correctness. This is called
409+ when loading a pyproject.toml. Checks:
330410
331- - ``metadata_version`` is a known version or None
332411 - ``name`` is a valid project name
333412 - ``license_files`` can't be used with classic ``license``
334413 - License classifiers can't be used with SPDX license
335414 - ``description`` is a single line (warning)
336- - ``license`` is not an SPDX license expression if metadata_version
337- >= 2.4 (warning)
338- - License classifiers deprecated for metadata_version >= 2.4 (warning)
339- - ``license`` is an SPDX license expression if metadata_version >= 2.4
340- - ``license_files`` is supported only for metadata_version >= 2.4
341415 - ``project_url`` can't contain keys over 32 characters
416+ - ``import-name(space)s`` must be valid names, optionally with ``; private``
417+ - ``import-names`` and ``import-namespaces`` cannot overlap
342418 """
343419 errors = ErrorCollector ()
344420
@@ -380,6 +456,23 @@ def validate(self) -> None:
380456 msg = "{key} names cannot be more than 32 characters long"
381457 errors .config_error (msg , key = "project.urls" , got = name )
382458
459+ import_names = set (
460+ _validate_import_names (
461+ self .import_names or [], "import-names" , errors = errors
462+ )
463+ )
464+ import_namespaces = set (
465+ _validate_import_names (
466+ self .import_namespaces or [], "import-namespaces" , errors = errors
467+ )
468+ )
469+ in_both = import_names & import_namespaces
470+ if in_both :
471+ msg = "{key} overlaps with 'project.import-namespaces': {in_both}"
472+ errors .config_error (msg , key = "project.import-names" , in_both = in_both )
473+
474+ _validate_dotted_names (import_names | import_namespaces , errors = errors )
475+
383476 errors .finalize ("[project] table validation failed" )
384477
385478 def metadata (
@@ -388,7 +481,7 @@ def metadata(
388481 """
389482 Return an Message with the metadata.
390483 """
391- self .validate_metdata (metadata_version )
484+ self .validate_metadata (metadata_version )
392485
393486 assert self .version is not None
394487 message = packaging_metadata .RawMetadata (
@@ -452,9 +545,19 @@ def metadata(
452545 for requirement in requirements
453546 )
454547 if self .readme :
455- if self .readme .content_type :
456- message ["description_content_type" ] = self .readme .content_type
548+ assert self .readme .content_type # verified earlier
549+ message ["description_content_type" ] = self .readme .content_type
457550 message ["description" ] = self .readme .text
551+
552+ if self .import_names is not None :
553+ # Special case for empty import-names
554+ if not self .import_names :
555+ message ["import_names" ] = ["" ]
556+ else :
557+ message ["import_names" ] = self .import_names
558+ if self .import_namespaces is not None :
559+ message ["import_namespaces" ] = self .import_namespaces
560+
458561 # Core Metadata 2.2
459562 if metadata_version != "2.1" :
460563 for field in dynamic_metadata :
0 commit comments