22
33"""
44This is pyproject_metadata, a library for working with PEP 621 metadata.
5+
6+ Example usage:
7+
8+ .. code-block:: python
9+
10+ from pyproject_metadata import StandardMetadata
11+
12+ metadata = StandardMetadata.from_pyproject(
13+ parsed_pyproject, allow_extra_keys=False, all_errors=True, metadata_version="2.3"
14+ )
15+
16+ pkg_info = metadata.as_rfc822()
17+ with open("METADATA", "wb") as f:
18+ f.write(pkg_info.as_bytes())
19+
20+ ep = self.metadata.entrypoints.copy()
21+ ep["console_scripts"] = self.metadata.scripts
22+ ep["gui_scripts"] = self.metadata.gui_scripts
23+ for group, entries in ep.items():
24+ if entries:
25+ with open("entry_points.txt", "w", encoding="utf-8") as f:
26+ print(f"[{group}]", file=f)
27+ for name, target in entries.items():
28+ print(f"{name} = {target}", file=f)
29+ print(file=f)
30+
531"""
632
733from __future__ import annotations
4268import packaging .utils
4369import packaging .version
4470
45- __version__ = "0.9.0b6 "
71+ __version__ = "0.9.0b7 "
4672
4773__all__ = [
4874 "ConfigurationError" ,
@@ -146,7 +172,7 @@ def set_payload(self, payload: str) -> None:
146172
147173class RFC822Policy (email .policy .EmailPolicy ):
148174 """
149- This is `email.policy.EmailPolicy`, but with a simple ``header_store_parse``
175+ This is :class: `email.policy.EmailPolicy`, but with a simple ``header_store_parse``
150176 implementation that handles multiline values, and some nice defaults.
151177 """
152178
@@ -165,7 +191,7 @@ def header_store_parse(self, name: str, value: str) -> tuple[str, str]:
165191
166192class RFC822Message (email .message .EmailMessage ):
167193 """
168- This is `email.message.EmailMessage` with two small changes: it defaults to
194+ This is :class: `email.message.EmailMessage` with two small changes: it defaults to
169195 our `RFC822Policy`, and it correctly writes unicode when being called
170196 with `bytes()`.
171197 """
@@ -184,6 +210,12 @@ def as_bytes(
184210
185211@dataclasses .dataclass
186212class StandardMetadata :
213+ """
214+ This class represents the standard metadata fields for a project. It can be
215+ used to read metadata from a pyproject.toml table, validate it, and write it
216+ to an RFC822 message or JSON.
217+ """
218+
187219 name : str
188220 version : packaging .version .Version | None = None
189221 description : str | None = None
@@ -207,14 +239,23 @@ class StandardMetadata:
207239 """
208240 This field is used to track dynamic fields. You can't set a field not in this list.
209241 """
242+
210243 dynamic_metadata : list [str ] = dataclasses .field (default_factory = list )
211244 """
212- This is a list of METADATA fields that can change inbetween SDist and wheel. Requires metadata_version 2.2+.
245+ This is a list of METADATA fields that can change in between SDist and wheel. Requires metadata_version 2.2+.
213246 """
214-
215247 metadata_version : str | None = None
248+ """
249+ This is the target metadata version. If None, it will be computed as a minimum based on the fields set.
250+ """
216251 all_errors : bool = False
252+ """
253+ If True, all errors will be collected and raised in an ExceptionGroup.
254+ """
217255 _locked_metadata : bool = False
256+ """
257+ Internal flag to prevent setting non-dynamic fields after initialization.
258+ """
218259
219260 def __post_init__ (self ) -> None :
220261 self .validate ()
@@ -228,85 +269,6 @@ def __setattr__(self, name: str, value: Any) -> None:
228269 raise AttributeError (msg )
229270 super ().__setattr__ (name , value )
230271
231- def validate (self , * , warn : bool = True ) -> None : # noqa: C901
232- """
233- Validate metadata for consistency and correctness. Will also produce warnings if
234- ``warn`` is given. Respects ``all_errors``. Checks:
235-
236- - ``metadata_version`` is a known version or None
237- - ``name`` is a valid project name
238- - ``license_files`` can't be used with classic ``license``
239- - License classifiers can't be used with SPDX license
240- - ``description`` is a single line (warning)
241- - ``license`` is not an SPDX license expression if metadata_version >= 2.4 (warning)
242- - License classifiers deprecated for metadata_version >= 2.4 (warning)
243- - ``license`` is an SPDX license expression if metadata_version >= 2.4
244- - ``license_files`` is supported only for metadata_version >= 2.4
245- """
246- errors = ErrorCollector (collect_errors = self .all_errors )
247-
248- if self .auto_metadata_version not in constants .KNOWN_METADATA_VERSIONS :
249- msg = f"The metadata_version must be one of { constants .KNOWN_METADATA_VERSIONS } or None (default)"
250- errors .config_error (msg )
251-
252- # See https://packaging.python.org/en/latest/specifications/core-metadata/#name and
253- # https://packaging.python.org/en/latest/specifications/name-normalization/#name-format
254- if not re .match (
255- r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$" , self .name , re .IGNORECASE
256- ):
257- msg = (
258- f"Invalid project name { self .name !r} . A valid name consists only of ASCII letters and "
259- "numbers, period, underscore and hyphen. It must start and end with a letter or number"
260- )
261- errors .config_error (msg , key = "project.name" )
262-
263- if self .license_files is not None and isinstance (self .license , License ):
264- msg = "'project.license-files' must not be used when 'project.license' is not a SPDX license expression"
265- errors .config_error (msg , key = "project.license-files" )
266-
267- if isinstance (self .license , str ) and any (
268- c .startswith ("License ::" ) for c in self .classifiers
269- ):
270- msg = "Setting 'project.license' to an SPDX license expression is not compatible with 'License ::' classifiers"
271- errors .config_error (msg , key = "project.license" )
272-
273- if warn :
274- if self .description and "\n " in self .description :
275- warnings .warn (
276- "The one-line summary 'project.description' should not contain more than one line. Readers might merge or truncate newlines." ,
277- ConfigurationWarning ,
278- stacklevel = 2 ,
279- )
280- if self .auto_metadata_version not in constants .PRE_SPDX_METADATA_VERSIONS :
281- if isinstance (self .license , License ):
282- warnings .warn (
283- "Set 'project.license' to an SPDX license expression for metadata >= 2.4" ,
284- ConfigurationWarning ,
285- stacklevel = 2 ,
286- )
287- elif any (c .startswith ("License ::" ) for c in self .classifiers ):
288- warnings .warn (
289- "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for 'project.license' instead" ,
290- ConfigurationWarning ,
291- stacklevel = 2 ,
292- )
293-
294- if (
295- isinstance (self .license , str )
296- and self .auto_metadata_version in constants .PRE_SPDX_METADATA_VERSIONS
297- ):
298- msg = "Setting 'project.license' to an SPDX license expression is supported only when emitting metadata version >= 2.4"
299- errors .config_error (msg , key = "project.license" )
300-
301- if (
302- self .license_files is not None
303- and self .auto_metadata_version in constants .PRE_SPDX_METADATA_VERSIONS
304- ):
305- msg = "'project.license-files' is supported only when emitting metadata version >= 2.4"
306- errors .config_error (msg , key = "project.license-files" )
307-
308- errors .finalize ("Metadata validation failed" )
309-
310272 @property
311273 def auto_metadata_version (self ) -> str :
312274 """
@@ -351,7 +313,7 @@ def from_pyproject( # noqa: C901
351313
352314 pyproject_table : PyProjectTable = data # type: ignore[assignment]
353315 if "project" not in pyproject_table :
354- msg = "Section 'project' missing in pyproject.toml"
316+ msg = "Section {key} missing in pyproject.toml"
355317 pyproject .config_error (msg , key = "project" )
356318 pyproject .finalize ("Failed to parse pyproject.toml" )
357319 msg = "Unreachable code" # pragma: no cover
@@ -364,24 +326,26 @@ def from_pyproject( # noqa: C901
364326 extra_keys = extras_project (data )
365327 if extra_keys :
366328 extra_keys_str = ", " .join (sorted (f"{ k !r} " for k in extra_keys ))
367- msg = f"Extra keys present in 'project': { extra_keys_str } "
368- if allow_extra_keys is None :
369- warnings .warn (msg , ConfigurationWarning , stacklevel = 2 )
370- else :
371- pyproject .config_error (msg )
329+ msg = "Extra keys present in {key}: {extra_keys}"
330+ pyproject .config_error (
331+ msg ,
332+ key = "project" ,
333+ extra_keys = extra_keys_str ,
334+ warn = allow_extra_keys is None ,
335+ )
372336
373337 dynamic = pyproject .get_dynamic (project )
374338
375339 for field in dynamic :
376340 if field in data ["project" ]:
377- msg = f" Field 'project. { field } ' declared as dynamic in ' project.dynamic' but is defined"
378- pyproject .config_error (msg , key = field )
341+ msg = ' Field {key} declared as dynamic in " project.dynamic" but is defined'
342+ pyproject .config_error (msg , key = f"project. { field } " )
379343
380344 raw_name = project .get ("name" )
381345 name = "UNKNOWN"
382346 if raw_name is None :
383- msg = "Field 'project.name' missing"
384- pyproject .config_error (msg , key = "name" )
347+ msg = "Field {key} missing"
348+ pyproject .config_error (msg , key = "project. name" )
385349 else :
386350 tmp_name = pyproject .ensure_str (raw_name , "project.name" )
387351 if tmp_name is not None :
@@ -399,11 +363,15 @@ def from_pyproject( # noqa: C901
399363 else None
400364 )
401365 except packaging .version .InvalidVersion :
402- msg = f"Invalid 'project.version' value, expecting a valid PEP 440 version (got { version_string !r} )"
403- pyproject .config_error (msg , key = "project.version" )
366+ msg = "Invalid {key} value, expecting a valid PEP 440 version"
367+ pyproject .config_error (
368+ msg , key = "project.version" , got = version_string
369+ )
404370 elif "version" not in dynamic :
405- msg = "Field 'project.version' missing and 'version' not specified in 'project.dynamic'"
406- pyproject .config_error (msg , key = "version" )
371+ msg = (
372+ "Field {key} missing and 'version' not specified in \" project.dynamic\" "
373+ )
374+ pyproject .config_error (msg , key = "project.version" )
407375
408376 # Description fills Summary, which cannot be multiline
409377 # However, throwing an error isn't backward compatible,
@@ -427,8 +395,10 @@ def from_pyproject( # noqa: C901
427395 requires_python_string
428396 )
429397 except packaging .specifiers .InvalidSpecifier :
430- msg = f"Invalid 'project.requires-python' value, expecting a valid specifier set (got { requires_python_string !r} )"
431- pyproject .config_error (msg , key = "project.requires-python" )
398+ msg = "Invalid {key} value, expecting a valid specifier set"
399+ pyproject .config_error (
400+ msg , key = "project.requires-python" , got = requires_python_string
401+ )
432402
433403 self = None
434404 with pyproject .collect ():
@@ -496,6 +466,86 @@ def as_json(self) -> dict[str, str | list[str]]:
496466 self ._write_metadata (smart_message )
497467 return message
498468
469+ def validate (self , * , warn : bool = True ) -> None : # noqa: C901
470+ """
471+ Validate metadata for consistency and correctness. Will also produce
472+ warnings if ``warn`` is given. Respects ``all_errors``. This is called
473+ when loading a pyproject.toml, and when making metadata. Checks:
474+
475+ - ``metadata_version`` is a known version or None
476+ - ``name`` is a valid project name
477+ - ``license_files`` can't be used with classic ``license``
478+ - License classifiers can't be used with SPDX license
479+ - ``description`` is a single line (warning)
480+ - ``license`` is not an SPDX license expression if metadata_version >= 2.4 (warning)
481+ - License classifiers deprecated for metadata_version >= 2.4 (warning)
482+ - ``license`` is an SPDX license expression if metadata_version >= 2.4
483+ - ``license_files`` is supported only for metadata_version >= 2.4
484+ """
485+ errors = ErrorCollector (collect_errors = self .all_errors )
486+
487+ if self .auto_metadata_version not in constants .KNOWN_METADATA_VERSIONS :
488+ msg = "The metadata_version must be one of {versions} or None (default)"
489+ errors .config_error (msg , versions = constants .KNOWN_METADATA_VERSIONS )
490+
491+ # See https://packaging.python.org/en/latest/specifications/core-metadata/#name and
492+ # https://packaging.python.org/en/latest/specifications/name-normalization/#name-format
493+ if not re .match (
494+ r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$" , self .name , re .IGNORECASE
495+ ):
496+ msg = (
497+ "Invalid project name {name!r}. A valid name consists only of ASCII letters and "
498+ "numbers, period, underscore and hyphen. It must start and end with a letter or number"
499+ )
500+ errors .config_error (msg , key = "project.name" , name = self .name )
501+
502+ if self .license_files is not None and isinstance (self .license , License ):
503+ msg = '{key} must not be used when "project.license" is not a SPDX license expression'
504+ errors .config_error (msg , key = "project.license-files" )
505+
506+ if isinstance (self .license , str ) and any (
507+ c .startswith ("License ::" ) for c in self .classifiers
508+ ):
509+ msg = "Setting {key} to an SPDX license expression is not compatible with 'License ::' classifiers"
510+ errors .config_error (msg , key = "project.license" )
511+
512+ if warn :
513+ if self .description and "\n " in self .description :
514+ warnings .warn (
515+ 'The one-line summary "project.description" should not contain more than one line. Readers might merge or truncate newlines.' ,
516+ ConfigurationWarning ,
517+ stacklevel = 2 ,
518+ )
519+ if self .auto_metadata_version not in constants .PRE_SPDX_METADATA_VERSIONS :
520+ if isinstance (self .license , License ):
521+ warnings .warn (
522+ 'Set "project.license" to an SPDX license expression for metadata >= 2.4' ,
523+ ConfigurationWarning ,
524+ stacklevel = 2 ,
525+ )
526+ elif any (c .startswith ("License ::" ) for c in self .classifiers ):
527+ warnings .warn (
528+ "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for \" project.license\" instead" ,
529+ ConfigurationWarning ,
530+ stacklevel = 2 ,
531+ )
532+
533+ if (
534+ isinstance (self .license , str )
535+ and self .auto_metadata_version in constants .PRE_SPDX_METADATA_VERSIONS
536+ ):
537+ msg = "Setting {key} to an SPDX license expression is supported only when emitting metadata version >= 2.4"
538+ errors .config_error (msg , key = "project.license" )
539+
540+ if (
541+ self .license_files is not None
542+ and self .auto_metadata_version in constants .PRE_SPDX_METADATA_VERSIONS
543+ ):
544+ msg = "{key} is supported only when emitting metadata version >= 2.4"
545+ errors .config_error (msg , key = "project.license-files" )
546+
547+ errors .finalize ("Metadata validation failed" )
548+
499549 def _write_metadata ( # noqa: C901
500550 self , smart_message : _SmartMessageSetter | _JSonMessageSetter
501551 ) -> None :
0 commit comments