Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 95 additions & 2 deletions pyproject_metadata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
import email.message
import email.policy
import email.utils
import itertools
import keyword
import os
import os.path
import pathlib
Expand All @@ -50,7 +52,7 @@
from .pyproject import License, PyProjectReader, Readme

if typing.TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Generator, Mapping
from typing import Any

from packaging.requirements import Requirement
Expand Down Expand Up @@ -137,7 +139,7 @@ class _SmartMessageSetter:
message: email.message.Message

def __setitem__(self, name: str, value: str | None) -> None:
if not value:
if value is None:
return
self.message[name] = value

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


def _validate_import_names(
names: list[str], key: str, *, errors: ErrorCollector
) -> Generator[str, None, None]:
"""
Returns normalized names for comparisons.
"""
for fullname in names:
name, simicolon, private = fullname.partition(";")
if simicolon and private.lstrip() != "private":
msg = "{key} contains an ending tag other than '; private', got {value!r}"
errors.config_error(msg, key=key, value=fullname)
name = name.rstrip()

for ident in name.split("."):
if not ident.isidentifier():
msg = "{key} contains {value!r}, which is not a valid identifier"
errors.config_error(msg, key=key, value=fullname)

elif keyword.iskeyword(ident):
msg = "{key} contains a Python keyword, which is not a valid import name, got {value!r}"
errors.config_error(msg, key=key, value=fullname)

yield name


def _validate_dotted_names(names: set[str], *, errors: ErrorCollector) -> None:
"""
Checks to make sure every name is accounted for. Takes the union of de-tagged names.
"""

for name in names:
for parent in itertools.accumulate(
name.split(".")[:-1], lambda a, b: f"{a}.{b}"
):
if parent not in names:
msg = "{key} is missing {value!r}, but submodules are present elsewhere"
errors.config_error(msg, key="project.import-namespaces", value=parent)
continue


class RFC822Message(email.message.EmailMessage):
"""
This is :class:`email.message.EmailMessage` with two small changes: it defaults to
Expand Down Expand Up @@ -271,6 +313,8 @@ class StandardMetadata:
keywords: list[str] = dataclasses.field(default_factory=list)
scripts: dict[str, str] = dataclasses.field(default_factory=dict)
gui_scripts: dict[str, str] = dataclasses.field(default_factory=dict)
import_names: list[str] | None = None
import_namespaces: list[str] | None = None
dynamic: list[Dynamic] = dataclasses.field(default_factory=list)
"""
This field is used to track dynamic fields. You can't set a field not in this list.
Expand Down Expand Up @@ -301,6 +345,8 @@ def auto_metadata_version(self) -> str:
if self.metadata_version is not None:
return self.metadata_version

if self.import_names is not None or self.import_namespaces is not None:
return "2.5"
if isinstance(self.license, str) or self.license_files is not None:
return "2.4"
if self.dynamic_metadata:
Expand Down Expand Up @@ -460,6 +506,12 @@ def from_pyproject( # noqa: C901
project.get("gui-scripts", {}), "project.gui-scripts"
)
or {},
import_names=pyproject.ensure_list(
project.get("import-names", None), "project.import-names"
),
import_namespaces=pyproject.ensure_list(
project.get("import-namespaces", None), "project.import-namespaces"
),
dynamic=dynamic,
dynamic_metadata=dynamic_metadata or [],
metadata_version=metadata_version,
Expand Down Expand Up @@ -504,6 +556,9 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901
- ``license`` is an SPDX license expression if metadata_version >= 2.4
- ``license_files`` is supported only for metadata_version >= 2.4
- ``project_url`` can't contain keys over 32 characters
- ``import-name(paces)s`` is only supported on metadata_version >= 2.5
- ``import-name(space)s`` must be valid names, optionally with ``; private``
- ``import-names`` and ``import-namespaces`` cannot overlap
"""
errors = ErrorCollector(collect_errors=self.all_errors)

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

if (
self.import_names is not None
and self.auto_metadata_version in constants.PRE_2_5_METADATA_VERSIONS
):
msg = "{key} is only supported when emitting metadata version >= 2.5"
errors.config_error(msg, key="project.import-names")

if (
self.import_namespaces is not None
and self.auto_metadata_version in constants.PRE_2_5_METADATA_VERSIONS
):
msg = "{key} is only supported when emitting metadata version >= 2.5"
errors.config_error(msg, key="project.import-namespaces")

import_names = set(
_validate_import_names(
self.import_names or [], "import-names", errors=errors
)
)
import_namespaces = set(
_validate_import_names(
self.import_namespaces or [], "import-namespaces", errors=errors
)
)
in_both = import_names & import_namespaces
if in_both:
msg = "{key} overlaps with 'project.import-namespaces': {in_both}"
errors.config_error(msg, key="project.import-names", in_both=in_both)

_validate_dotted_names(import_names | import_namespaces, errors=errors)

errors.finalize("Metadata validation failed")

def _write_metadata( # noqa: C901
Expand Down Expand Up @@ -637,6 +723,13 @@ def _write_metadata( # noqa: C901
if self.readme.content_type:
smart_message["Description-Content-Type"] = self.readme.content_type
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 []:
smart_message["Import-Namespace"] = import_namespace
# Special case for empty import-names
if self.import_names is not None and not self.import_names:
smart_message["Import-Name"] = ""
# Core Metadata 2.2
if self.auto_metadata_version != "2.1":
for field in self.dynamic_metadata:
Expand Down
9 changes: 8 additions & 1 deletion pyproject_metadata/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ def __dir__() -> list[str]:
return __all__


KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"}
KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4", "2.5"}
PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"}
PRE_2_5_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"}

PROJECT_TO_METADATA = {
"authors": frozenset(["Author", "Author-Email"]),
Expand All @@ -46,6 +47,8 @@ def __dir__() -> list[str]:
"scripts": frozenset(),
"urls": frozenset(["Project-URL"]),
"version": frozenset(["Version"]),
"import-names": frozenset(["Import-Name"]),
"import-namespaces": frozenset(["Import-Namespaces"]),
}

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

KNOWN_MULTIUSE = {
Expand All @@ -100,4 +105,6 @@ def __dir__() -> list[str]:
"requires", # Deprecated
"obsoletes", # Deprecated
"provides", # Deprecated
"import-name",
"import-namespace",
}
4 changes: 4 additions & 0 deletions pyproject_metadata/project_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ class LicenseTable(TypedDict, total=False):
"scripts",
"urls",
"version",
"import-names",
"import-namespaces",
]

ProjectTable = TypedDict(
Expand All @@ -90,6 +92,8 @@ class LicenseTable(TypedDict, total=False):
"keywords": List[str],
"scripts": Dict[str, str],
"gui-scripts": Dict[str, str],
"import-names": List[str],
"import-namespaces": List[str],
"dynamic": List[Dynamic],
},
total=False,
Expand Down
4 changes: 3 additions & 1 deletion pyproject_metadata/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ def ensure_str(self, value: str, key: str) -> str | None:
self.config_error(msg, key=key, got_type=type(value))
return None

def ensure_list(self, val: list[T], key: str) -> list[T] | None:
def ensure_list(self, val: list[T] | None, key: str) -> list[T] | None:
"""Ensure that a value is a list of strings."""
if val is None:
return None
if not isinstance(val, list):
msg = "Field {key} has an invalid type, expecting a list of strings"
self.config_error(msg, key=key, got_type=type(val))
Expand Down
20 changes: 20 additions & 0 deletions tests/packages/metadata-2.5/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Copyright © 2019 Filipe Laíns <[email protected]>

Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
1 change: 1 addition & 0 deletions tests/packages/metadata-2.5/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
some readme 👋
Empty file.
51 changes: 51 additions & 0 deletions tests/packages/metadata-2.5/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
[project]
name = 'metadata25'
version = '3.2.1'
description = 'A package with all the metadata :)'
readme = 'README.md'
license = "MIT"
license-files = ["LICENSE"]
keywords = ['trampolim', 'is', 'interesting']
authors = [
{ email = '[email protected]' },
{ name = 'Example!' },
]
maintainers = [
{ name = 'Other Example', email = '[email protected]' },
]
classifiers = [
'Development Status :: 4 - Beta',
'Programming Language :: Python',
]

requires-python = '>=3.8'
dependencies = [
'dependency1',
'dependency2>1.0.0',
'dependency3[extra]',
'dependency4; os_name != "nt"',
'dependency5[other-extra]>1.0; os_name == "nt"',
]
import-names = ["metadata25"]

[project.optional-dependencies]
test = [
'test_dependency',
'test_dependency[test_extra]',
'test_dependency[test_extra2] > 3.0; os_name == "nt"',
]

[project.urls]
homepage = 'example.com'
documentation = 'readthedocs.org'
repository = 'github.com/some/repo'
changelog = 'github.com/some/repo/blob/master/CHANGELOG.rst'

[project.scripts]
full-metadata = 'full_metadata:main_cli'

[project.gui-scripts]
full-metadata-gui = 'full_metadata:main_gui'

[project.entry-points.custom]
full-metadata = 'full_metadata:main_custom'
Loading