Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PEP 771] #4813

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ keywords = "setuptools.dist:Distribution._finalize_setup_keywords"
eager_resources = "setuptools.dist:assert_string_list"
namespace_packages = "setuptools.dist:check_nsp"
extras_require = "setuptools.dist:check_extras"
default_extras_require = "setuptools.dist:check_default_extras_require"
install_requires = "setuptools.dist:check_requirements"
setup_requires = "setuptools.dist:check_requirements"
python_requires = "setuptools.dist:check_specifier"
Expand Down
6 changes: 5 additions & 1 deletion setuptools/_core_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
def get_metadata_version(self):
mv = getattr(self, 'metadata_version', None)
if mv is None:
mv = Version('2.2')
mv = Version('2.5')
self.metadata_version = mv
return mv

Expand Down Expand Up @@ -223,6 +223,9 @@ def _write_requirements(self, file):
for req in _reqs.parse(self.install_requires):
file.write(f"Requires-Dist: {req}\n")

for req in _reqs.parse(self.default_extras_require):
file.write(f"Default-Extra: {req}\n")

processed_extras = {}
for augmented_extra, reqs in self.extras_require.items():
# Historically, setuptools allows "augmented extras": `<extra>:<condition>`
Expand Down Expand Up @@ -311,6 +314,7 @@ def _distribution_fullname(name: str, version: str) -> str:
"project-url": "project_urls",
"provides": "provides",
# "provides-dist": "provides_dist", # NOT USED
"default-extra": "default_extras_require",
"provides-extra": "extras_require",
"requires": "requires",
"requires-dist": "install_requires",
Expand Down
1 change: 1 addition & 0 deletions setuptools/_vendor/importlib_metadata/_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class Message(email.message.Message):
'Platform',
'Project-URL',
'Provides-Dist',
'Default-Extra'
'Provides-Extra',
'Requires-Dist',
'Requires-External',
Expand Down
14 changes: 12 additions & 2 deletions setuptools/_vendor/packaging/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ class RawMetadata(TypedDict, total=False):
license_expression: str
license_files: list[str]

# Metadata 2.5 - PEP 771
default_extra: list[str]


_STRING_FIELDS = {
"author",
Expand Down Expand Up @@ -253,6 +256,7 @@ def _get_payload(msg: email.message.Message, source: bytes | str) -> str:
"author-email": "author_email",
"classifier": "classifiers",
"description": "description",
"default-extra": "default_extra",
"description-content-type": "description_content_type",
"download-url": "download_url",
"dynamic": "dynamic",
Expand Down Expand Up @@ -463,8 +467,8 @@ def parse_email(data: bytes | str) -> tuple[RawMetadata, dict[str, list[str]]]:


# Keep the two values in sync.
_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]
_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]
_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5"]

_REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"])

Expand Down Expand Up @@ -861,3 +865,9 @@ def from_email(cls, data: bytes | str, *, validate: bool = True) -> Metadata:
"""``Provides`` (deprecated)"""
obsoletes: _Validator[list[str] | None] = _Validator(added="1.1")
"""``Obsoletes`` (deprecated)"""
# PEP 771 lets us define a default `extras_require` if none is passed by the
# user.
default_extra: _Validator[list[utils.NormalizedName] | None] = _Validator(
added="2.5",
)
""":external:ref:`core-metadata-default-extra`"""
4 changes: 2 additions & 2 deletions setuptools/_vendor/wheel/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,12 @@ def generate_requirements(

def pkginfo_to_metadata(egg_info_path: str, pkginfo_path: str) -> Message:
"""
Convert .egg-info directory with PKG-INFO to the Metadata 2.1 format
Convert .egg-info directory with PKG-INFO to the Metadata 2.5 format
"""
with open(pkginfo_path, encoding="utf-8") as headers:
pkg_info = Parser().parse(headers)

pkg_info.replace_header("Metadata-Version", "2.1")
pkg_info.replace_header("Metadata-Version", "2.5")
# Those will be regenerated from `requires.txt`.
del pkg_info["Provides-Extra"]
del pkg_info["Requires-Dist"]
Expand Down
10 changes: 10 additions & 0 deletions setuptools/config/_apply_pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ def _dependencies(dist: Distribution, val: list, _root_dir: StrPath | None):
dist.install_requires = val


def _default_optional_dependencies(dist: Distribution, val: dict, _root_dir: StrPath | None):
if getattr(dist, "default_extras_require", None):
msg = "`default_extras_require` overwritten in `pyproject.toml` (default-optional-dependencies)"
SetuptoolsWarning.emit(msg)
dist.default_extras_require = val


def _optional_dependencies(dist: Distribution, val: dict, _root_dir: StrPath | None):
if getattr(dist, "extras_require", None):
msg = "`extras_require` overwritten in `pyproject.toml` (optional-dependencies)"
Expand Down Expand Up @@ -396,6 +403,7 @@ def _acessor(obj):
"urls": _project_urls,
"dependencies": _dependencies,
"optional_dependencies": _optional_dependencies,
"default_optional_dependencies": _default_optional_dependencies,
"requires_python": _python_requires,
}

Expand Down Expand Up @@ -441,6 +449,7 @@ def _acessor(obj):
"scripts": _get_previous_scripts,
"gui-scripts": _get_previous_gui_scripts,
"dependencies": _attrgetter("install_requires"),
"default-optional-dependencies": _attrgetter("default_extras_require"),
"optional-dependencies": _attrgetter("extras_require"),
}

Expand All @@ -458,6 +467,7 @@ def _acessor(obj):
"scripts": _static.EMPTY_DICT,
"gui-scripts": _static.EMPTY_DICT,
"dependencies": _static.EMPTY_LIST,
"default-optional-dependencies": _static.EMPTY_LIST,
"optional-dependencies": _static.EMPTY_DICT,
}

Expand Down
32 changes: 31 additions & 1 deletion setuptools/config/_validate_pyproject/extra_validations.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ class RedefiningStaticFieldAsDynamic(ValidationError):
)


class IncludedDependencyGroupMustExist(ValidationError):
_DESC = """An included dependency group must exist and must not be cyclic.
"""
__doc__ = _DESC
_URL = "https://peps.python.org/pep-0735/"


def validate_project_dynamic(pyproject: T) -> T:
project_table = pyproject.get("project", {})
dynamic = project_table.get("dynamic", [])
Expand All @@ -49,4 +56,27 @@ def validate_project_dynamic(pyproject: T) -> T:
return pyproject


EXTRA_VALIDATIONS = (validate_project_dynamic,)
def validate_include_depenency(pyproject: T) -> T:
dependency_groups = pyproject.get("dependency-groups", {})
for key, value in dependency_groups.items():
for each in value:
if (
isinstance(each, dict)
and (include_group := each.get("include-group"))
and include_group not in dependency_groups
):
raise IncludedDependencyGroupMustExist(
message=f"The included dependency group {include_group} doesn't exist",
value=each,
name=f"data.dependency_groups.{key}",
definition={
"description": cleandoc(IncludedDependencyGroupMustExist._DESC),
"see": IncludedDependencyGroupMustExist._URL,
},
rule="PEP 735",
)
# TODO: check for `include-group` cycles (can be conditional to graphlib)
return pyproject


EXTRA_VALIDATIONS = (validate_project_dynamic, validate_include_depenency)
377 changes: 242 additions & 135 deletions setuptools/config/_validate_pyproject/fastjsonschema_validations.py

Large diffs are not rendered by default.

33 changes: 30 additions & 3 deletions setuptools/config/_validate_pyproject/formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,15 @@ class _TroveClassifier:
"""

downloaded: typing.Union[None, "Literal[False]", typing.Set[str]]
"""
None => not cached yet
False => unavailable
set => cached values
"""

def __init__(self) -> None:
self.downloaded = None
self._skip_download = False
# None => not cached yet
# False => cache not available
self.__name__ = "trove_classifier" # Emulate a public function

def _disable_download(self) -> None:
Expand Down Expand Up @@ -351,7 +354,7 @@ def python_entrypoint_reference(value: str) -> bool:
obj = rest

module_parts = module.split(".")
identifiers = _chain(module_parts, obj.split(".")) if rest else module_parts
identifiers = _chain(module_parts, obj.split(".")) if rest else iter(module_parts)
return all(python_identifier(i.strip()) for i in identifiers)


Expand All @@ -373,3 +376,27 @@ def uint(value: builtins.int) -> bool:
def int(value: builtins.int) -> bool:
r"""Signed 64-bit integer (:math:`-2^{63} \leq x < 2^{63}`)"""
return -(2**63) <= value < 2**63


try:
from packaging import licenses as _licenses

def SPDX(value: str) -> bool:
"""See :ref:`PyPA's License-Expression specification
<pypa:core-metadata-license-expression>` (added in :pep:`639`).
"""
try:
_licenses.canonicalize_license_expression(value)
return True
except _licenses.InvalidLicenseExpression:
return False

except ImportError: # pragma: no cover
_logger.warning(
"Could not find an up-to-date installation of `packaging`. "
"License expressions might not be validated. "
"To enforce validation, please install `packaging>=24.2`."
)

def SPDX(value: str) -> bool:
return True
11 changes: 11 additions & 0 deletions setuptools/config/pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ def _expand_all_dynamic(self, dist: Distribution, package_dir: Mapping[str, str]
"gui-scripts",
"classifiers",
"dependencies",
"default-optional-dependencies",
"optional-dependencies",
)
# `_obtain` functions are assumed to raise appropriate exceptions/warnings.
Expand All @@ -263,6 +264,7 @@ def _expand_all_dynamic(self, dist: Distribution, package_dir: Mapping[str, str]
readme=self._obtain_readme(dist),
classifiers=self._obtain_classifiers(dist),
dependencies=self._obtain_dependencies(dist),
default_optional_dependencies=self._obtain_default_optional_dependencies(dist),
optional_dependencies=self._obtain_optional_dependencies(dist),
)
# `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
Expand Down Expand Up @@ -370,6 +372,15 @@ def _obtain_dependencies(self, dist: Distribution):
return _parse_requirements_list(value)
return None

def _obtain_default_optional_dependencies(self, dist: Distribution):
if "default-optional-dependencies" in self.dynamic:
value = self._obtain(dist, "default-optional-dependencies", {})
if value:
return _parse_requirements_list(value)
assert "extras_require" in dist.__dict__
assert "default_extras_require" in dist.__dict__
return None

def _obtain_optional_dependencies(self, dist: Distribution):
if "optional-dependencies" not in self.dynamic:
return None
Expand Down
1 change: 1 addition & 0 deletions setuptools/config/setuptools.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@
"description": {"$ref": "#/definitions/file-directive"},
"entry-points": {"$ref": "#/definitions/file-directive"},
"dependencies": {"$ref": "#/definitions/file-directive-for-dependencies"},
"default-optional-dependencies": {"$ref": "#/definitions/attr-directive"},
"optional-dependencies": {
"type": "object",
"propertyNames": {"type": "string", "format": "pep508-identifier"},
Expand Down
31 changes: 30 additions & 1 deletion setuptools/dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ def check_nsp(dist, attr, value):
)


def check_default_extras_require(dist, attr, value):
"""Verify that extras_require mapping is valid"""
try:
assert isinstance(value, (list, tuple))
assert all(isinstance(val, str) for val in value)
except AssertionError as e:
raise DistutilsSetupError(
"'default_extras_require' must be a a list whose values are strings."
) from e


def check_extras(dist, attr, value):
"""Verify that extras_require mapping is valid"""
try:
Expand Down Expand Up @@ -292,6 +303,7 @@ class Distribution(_Distribution):
'license_files': lambda: None,
'install_requires': list,
'extras_require': dict,
'default_extras_require': list,
}

# Used by build_py, editable_wheel and install_lib commands for legacy namespaces
Expand All @@ -314,7 +326,11 @@ def __init__(self, attrs: MutableMapping[str, Any] | None = None) -> None:
vars(self).setdefault(ep.name, None)

metadata_only = set(self._DISTUTILS_UNSUPPORTED_METADATA)
metadata_only -= {"install_requires", "extras_require"}
metadata_only -= {
"install_requires",
"extras_require",
"default_extras_require",
}
dist_attrs = {k: v for k, v in attrs.items() if k not in metadata_only}
_Distribution.__init__(self, dist_attrs)

Expand Down Expand Up @@ -380,6 +396,7 @@ def _finalize_requires(self):
self._normalize_requires()
self.metadata.install_requires = self.install_requires
self.metadata.extras_require = self.extras_require
self.metadata.default_extras_require = self.default_extras_require

if self.extras_require:
for extra in self.extras_require.keys():
Expand All @@ -392,6 +409,7 @@ def _normalize_requires(self):
"""Make sure requirement-related attributes exist and are normalized"""
install_requires = getattr(self, "install_requires", None) or []
extras_require = getattr(self, "extras_require", None) or {}
default_extras_require = getattr(self, "default_extras_require", None) or []

# Preserve the "static"-ness of values parsed from config files
list_ = _static.List if _static.is_static(install_requires) else list
Expand All @@ -402,6 +420,17 @@ def _normalize_requires(self):
(k, list(map(str, _reqs.parse(v or [])))) for k, v in extras_require.items()
)

self.default_extras_require = []
for extra in default_extras_require:
if extra not in self.extras_require:
raise ValueError(
f"The default-extra `{extra}` does not exists in `extras_require`."
)
self.default_extras_require.append(extra)

list_ = _static.List if _static.is_static(default_extras_require) else list
self.default_extras_require = list_(self.default_extras_require)

def _finalize_license_files(self) -> None:
"""Compute names of all license files which should be included."""
license_files: list[str] | None = self.metadata.license_files
Expand Down