This commit is contained in:
lz_db
2026-10-16 22:48:30 +08:00
parent 190de72571
commit 0b36fd3383
3161 changed files with 12787 additions and 77274 deletions
@@ -2,53 +2,45 @@
Load setuptools configuration from ``setup.cfg`` files.
**API will be made private in the future**
To read project metadata, consider using
``build.util.project_wheel_metadata`` (https://pypi.org/project/build/).
For simple scenarios, you can also try parsing the file directly
with the help of ``configparser``.
"""
from __future__ import annotations
import os
import contextlib
import functools
import os
import warnings
from collections import defaultdict
from collections.abc import Iterable, Iterator
from functools import partial, wraps
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, TypeVar, cast
from functools import partial
from functools import wraps
from typing import (TYPE_CHECKING, Callable, Any, Dict, Generic, Iterable, List,
Optional, Tuple, TypeVar, Union)
from packaging.markers import default_environment as marker_env
from packaging.requirements import InvalidRequirement, Requirement
from packaging.version import InvalidVersion, Version
from distutils.errors import DistutilsOptionError, DistutilsFileError
from setuptools.extern.packaging.requirements import Requirement, InvalidRequirement
from setuptools.extern.packaging.version import Version, InvalidVersion
from setuptools.extern.packaging.specifiers import SpecifierSet
from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
from .. import _static
from .._path import StrPath
from ..errors import FileError, OptionError
from ..warnings import SetuptoolsDeprecationWarning
from . import expand
if TYPE_CHECKING:
from typing_extensions import TypeAlias
from setuptools.dist import Distribution # noqa
from distutils.dist import DistributionMetadata # noqa
from setuptools.dist import Distribution
from distutils.dist import DistributionMetadata
SingleCommandOptions: TypeAlias = dict[str, tuple[str, Any]]
_Path = Union[str, os.PathLike]
SingleCommandOptions = Dict["str", Tuple["str", Any]]
"""Dict that associate the name of the options of a particular command to a
tuple. The first element of the tuple indicates the origin of the option value
(e.g. the name of the configuration file where it was read from),
while the second element of the tuple is the option value itself
"""
AllCommandOptions: TypeAlias = dict[str, SingleCommandOptions]
"""cmd name => its options"""
Target = TypeVar("Target", "Distribution", "DistributionMetadata")
AllCommandOptions = Dict["str", SingleCommandOptions] # cmd name => its options
Target = TypeVar("Target", bound=Union["Distribution", "DistributionMetadata"])
def read_configuration(
filepath: StrPath, find_others: bool = False, ignore_option_errors: bool = False
filepath: _Path,
find_others=False,
ignore_option_errors=False
) -> dict:
"""Read given configuration file and returns options from it as a dict.
@@ -73,7 +65,7 @@ def read_configuration(
return configuration_to_dict(handlers)
def apply_configuration(dist: Distribution, filepath: StrPath) -> Distribution:
def apply_configuration(dist: "Distribution", filepath: _Path) -> "Distribution":
"""Apply the configuration from a ``setup.cfg`` file into an existing
distribution object.
"""
@@ -83,26 +75,24 @@ def apply_configuration(dist: Distribution, filepath: StrPath) -> Distribution:
def _apply(
dist: Distribution,
filepath: StrPath,
other_files: Iterable[StrPath] = (),
dist: "Distribution", filepath: _Path,
other_files: Iterable[_Path] = (),
ignore_option_errors: bool = False,
) -> tuple[ConfigMetadataHandler, ConfigOptionsHandler]:
) -> Tuple["ConfigHandler", ...]:
"""Read configuration from ``filepath`` and applies to the ``dist`` object."""
from setuptools.dist import _Distribution
filepath = os.path.abspath(filepath)
if not os.path.isfile(filepath):
raise FileError(f'Configuration file {filepath} does not exist.')
raise DistutilsFileError('Configuration file %s does not exist.' % filepath)
current_directory = os.getcwd()
os.chdir(os.path.dirname(filepath))
filenames = [*other_files, filepath]
try:
# TODO: Temporary cast until mypy 1.12 is released with upstream fixes from typeshed
_Distribution.parse_config_files(dist, filenames=cast(list[str], filenames))
_Distribution.parse_config_files(dist, filenames=filenames)
handlers = parse_configuration(
dist, dist.command_options, ignore_option_errors=ignore_option_errors
)
@@ -113,26 +103,22 @@ def _apply(
return handlers
def _get_option(target_obj: Distribution | DistributionMetadata, key: str):
def _get_option(target_obj: Target, key: str):
"""
Given a target object and option key, get that option from
the target object, either through a get_{key} method or
from an attribute directly.
"""
getter_name = f'get_{key}'
getter_name = 'get_{key}'.format(**locals())
by_attribute = functools.partial(getattr, target_obj, key)
getter = getattr(target_obj, getter_name, by_attribute)
return getter()
def configuration_to_dict(
handlers: Iterable[
ConfigHandler[Distribution] | ConfigHandler[DistributionMetadata]
],
) -> dict:
def configuration_to_dict(handlers: Tuple["ConfigHandler", ...]) -> dict:
"""Returns configuration data gathered by given handlers as a dict.
:param Iterable[ConfigHandler] handlers: Handlers list,
:param list[ConfigHandler] handlers: Handlers list,
usually from parse_configuration()
:rtype: dict
@@ -148,10 +134,10 @@ def configuration_to_dict(
def parse_configuration(
distribution: Distribution,
distribution: "Distribution",
command_options: AllCommandOptions,
ignore_option_errors: bool = False,
) -> tuple[ConfigMetadataHandler, ConfigOptionsHandler]:
ignore_option_errors=False
) -> Tuple["ConfigMetadataHandler", "ConfigOptionsHandler"]:
"""Performs additional parsing of configuration options
for a distribution.
@@ -186,9 +172,6 @@ def parse_configuration(
distribution.src_root,
)
meta.parse()
distribution._referenced_files.update(
options._referenced_files, meta._referenced_files
)
return meta, options
@@ -213,16 +196,17 @@ def _warn_accidental_env_marker_misconfig(label: str, orig_value: str, parsed: l
if "\n" in orig_value or len(parsed) != 2:
return
markers = marker_env().keys()
try:
req = Requirement(parsed[1])
if req.name in markers:
_AmbiguousMarker.emit(field=label, req=parsed[1])
except InvalidRequirement as ex:
if any(parsed[1].startswith(marker) for marker in markers):
msg = _AmbiguousMarker.message(field=label, req=parsed[1])
raise InvalidRequirement(msg) from ex
with contextlib.suppress(InvalidRequirement):
original_requirements_str = ";".join(parsed)
req = Requirement(original_requirements_str)
if req.marker is not None:
msg = (
f"One of the parsed requirements in `{label}` "
f"looks like a valid environment marker: '{parsed[1]}'\n"
"Make sure that the config is correct and check "
"https://setuptools.pypa.io/en/latest/userguide/declarative_config.html#opt-2" # noqa: E501
)
warnings.warn(msg, UserWarning)
class ConfigHandler(Generic[Target]):
@@ -234,7 +218,7 @@ class ConfigHandler(Generic[Target]):
"""
aliases: ClassVar[dict[str, str]] = {}
aliases: Dict[str, str] = {}
"""Options aliases.
For compatibility with various packages. E.g.: d2to1 and pbr.
Note: `-` in keys is replaced with `_` by config parser.
@@ -247,57 +231,65 @@ class ConfigHandler(Generic[Target]):
options: AllCommandOptions,
ignore_option_errors,
ensure_discovered: expand.EnsurePackagesDiscovered,
) -> None:
self.ignore_option_errors = ignore_option_errors
self.target_obj: Target = target_obj
self.sections = dict(self._section_options(options))
self.set_options: list[str] = []
self.ensure_discovered = ensure_discovered
self._referenced_files = set[str]()
"""After parsing configurations, this property will enumerate
all files referenced by the "file:" directive. Private API for setuptools only.
"""
):
sections: AllCommandOptions = {}
@classmethod
def _section_options(
cls, options: AllCommandOptions
) -> Iterator[tuple[str, SingleCommandOptions]]:
for full_name, value in options.items():
pre, _sep, name = full_name.partition(cls.section_prefix)
if pre:
section_prefix = self.section_prefix
for section_name, section_options in options.items():
if not section_name.startswith(section_prefix):
continue
yield name.lstrip('.'), value
section_name = section_name.replace(section_prefix, '').strip('.')
sections[section_name] = section_options
self.ignore_option_errors = ignore_option_errors
self.target_obj = target_obj
self.sections = sections
self.set_options: List[str] = []
self.ensure_discovered = ensure_discovered
@property
def parsers(self):
"""Metadata item name to parser function mapping."""
raise NotImplementedError(
f'{self.__class__.__name__} must provide .parsers property'
'%s must provide .parsers property' % self.__class__.__name__
)
def __setitem__(self, option_name, value) -> None:
def __setitem__(self, option_name, value):
unknown = tuple()
target_obj = self.target_obj
# Translate alias into real name.
option_name = self.aliases.get(option_name, option_name)
try:
current_value = getattr(target_obj, option_name)
except AttributeError as e:
raise KeyError(option_name) from e
current_value = getattr(target_obj, option_name, unknown)
if current_value is unknown:
raise KeyError(option_name)
if current_value:
# Already inhabited. Skipping.
return
try:
parsed = self.parsers.get(option_name, lambda x: x)(value)
except (Exception,) * self.ignore_option_errors:
skip_option = False
parser = self.parsers.get(option_name)
if parser:
try:
value = parser(value)
except Exception:
skip_option = True
if not self.ignore_option_errors:
raise
if skip_option:
return
simple_setter = functools.partial(target_obj.__setattr__, option_name)
setter = getattr(target_obj, f"set_{option_name}", simple_setter)
setter(parsed)
setter = getattr(target_obj, 'set_%s' % option_name, None)
if setter is None:
setattr(target_obj, option_name, value)
else:
setter(value)
self.set_options.append(option_name)
@@ -333,7 +325,9 @@ class ConfigHandler(Generic[Target]):
for line in cls._parse_list(value):
key, sep, val = line.partition(separator)
if sep != separator:
raise OptionError(f"Unable to parse option value to dict: {value}")
raise DistutilsOptionError(
'Unable to parse option value to dict: %s' % value
)
result[key.strip()] = val.strip()
return result
@@ -364,14 +358,15 @@ class ConfigHandler(Generic[Target]):
exclude_directive = 'file:'
if value.startswith(exclude_directive):
raise ValueError(
f'Only strings are accepted for the {key} field, '
'files are not accepted'
'Only strings are accepted for the {0} field, '
'files are not accepted'.format(key)
)
return _static.Str(value)
return value
return parser
def _parse_file(self, value, root_dir: StrPath | None):
@classmethod
def _parse_file(cls, value, root_dir: _Path):
"""Represents value as a string, allowing including text
from nearest files using `file:` directive.
@@ -390,15 +385,13 @@ class ConfigHandler(Generic[Target]):
return value
if not value.startswith(include_directive):
return _static.Str(value)
return value
spec = value[len(include_directive) :]
filepaths = [path.strip() for path in spec.split(',')]
self._referenced_files.update(filepaths)
# XXX: Is marking as static contents coming from files too optimistic?
return _static.Str(expand.read_files(filepaths, root_dir))
filepaths = (path.strip() for path in spec.split(','))
return expand.read_files(filepaths, root_dir)
def _parse_attr(self, value, package_dir, root_dir: StrPath):
def _parse_attr(self, value, package_dir, root_dir: _Path):
"""Represents value as a module attribute.
Examples:
@@ -410,7 +403,7 @@ class ConfigHandler(Generic[Target]):
"""
attr_directive = 'attr:'
if not value.startswith(attr_directive):
return _static.Str(value)
return value
attr_desc = value.replace(attr_directive, '')
@@ -466,58 +459,60 @@ class ConfigHandler(Generic[Target]):
parser = (lambda _, v: values_parser(v)) if values_parser else (lambda _, v: v)
return cls._parse_section_to_dict_with_key(section_options, parser)
def parse_section(self, section_options) -> None:
def parse_section(self, section_options):
"""Parses configuration file section.
:param dict section_options:
"""
for name, (_, value) in section_options.items():
for (name, (_, value)) in section_options.items():
with contextlib.suppress(KeyError):
# Keep silent for a new option may appear anytime.
self[name] = value
def parse(self) -> None:
def parse(self):
"""Parses configuration file items from one
or more related sections.
"""
for section_name, section_options in self.sections.items():
method_postfix = ''
if section_name: # [section.option] variant
method_postfix = f"_{section_name}"
method_postfix = '_%s' % section_name
section_parser_method: Callable | None = getattr(
section_parser_method: Optional[Callable] = getattr(
self,
# Dots in section names are translated into dunderscores.
f'parse_section{method_postfix}'.replace('.', '__'),
('parse_section%s' % method_postfix).replace('.', '__'),
None,
)
if section_parser_method is None:
raise OptionError(
"Unsupported distribution option section: "
f"[{self.section_prefix}.{section_name}]"
raise DistutilsOptionError(
'Unsupported distribution option section: [%s.%s]'
% (self.section_prefix, section_name)
)
section_parser_method(section_options)
def _deprecated_config_handler(self, func, msg, **kw):
def _deprecated_config_handler(self, func, msg, warning_class):
"""this function will wrap around parameters that are deprecated
:param msg: deprecation message
:param warning_class: class of warning exception to be raised
:param func: function to be wrapped around
"""
@wraps(func)
def config_handler(*args, **kwargs):
kw.setdefault("stacklevel", 2)
_DeprecatedConfig.emit("Deprecated config in `setup.cfg`", msg, **kw)
warnings.warn(msg, warning_class)
return func(*args, **kwargs)
return config_handler
class ConfigMetadataHandler(ConfigHandler["DistributionMetadata"]):
section_prefix = 'metadata'
aliases = {
@@ -535,13 +530,13 @@ class ConfigMetadataHandler(ConfigHandler["DistributionMetadata"]):
def __init__(
self,
target_obj: DistributionMetadata,
target_obj: "DistributionMetadata",
options: AllCommandOptions,
ignore_option_errors: bool,
ensure_discovered: expand.EnsurePackagesDiscovered,
package_dir: dict | None = None,
root_dir: StrPath | None = os.curdir,
) -> None:
package_dir: Optional[dict] = None,
root_dir: _Path = os.curdir
):
super().__init__(target_obj, options, ignore_option_errors, ensure_discovered)
self.package_dir = package_dir
self.root_dir = root_dir
@@ -549,29 +544,35 @@ class ConfigMetadataHandler(ConfigHandler["DistributionMetadata"]):
@property
def parsers(self):
"""Metadata item name to parser function mapping."""
parse_list_static = self._get_parser_compound(self._parse_list, _static.List)
parse_dict_static = self._get_parser_compound(self._parse_dict, _static.Dict)
parse_list = self._parse_list
parse_file = partial(self._parse_file, root_dir=self.root_dir)
parse_dict = self._parse_dict
exclude_files_parser = self._exclude_files_parser
return {
'author': _static.Str,
'author_email': _static.Str,
'maintainer': _static.Str,
'maintainer_email': _static.Str,
'platforms': parse_list_static,
'keywords': parse_list_static,
'provides': parse_list_static,
'obsoletes': parse_list_static,
'classifiers': self._get_parser_compound(parse_file, parse_list_static),
'platforms': parse_list,
'keywords': parse_list,
'provides': parse_list,
'requires': self._deprecated_config_handler(
parse_list,
"The requires parameter is deprecated, please use "
"install_requires for runtime dependencies.",
SetuptoolsDeprecationWarning,
),
'obsoletes': parse_list,
'classifiers': self._get_parser_compound(parse_file, parse_list),
'license': exclude_files_parser('license'),
'license_files': parse_list_static,
'license_file': self._deprecated_config_handler(
exclude_files_parser('license_file'),
"The license_file parameter is deprecated, "
"use license_files instead.",
SetuptoolsDeprecationWarning,
),
'license_files': parse_list,
'description': parse_file,
'long_description': parse_file,
'long_description_content_type': _static.Str,
'version': self._parse_version, # Cannot be marked as dynamic
'url': _static.Str,
'project_urls': parse_dict_static,
'version': self._parse_version,
'project_urls': parse_dict,
}
def _parse_version(self, value):
@@ -589,11 +590,12 @@ class ConfigMetadataHandler(ConfigHandler["DistributionMetadata"]):
# accidentally include newlines and other unintended content
try:
Version(version)
except InvalidVersion as e:
raise OptionError(
f'Version loaded from {value} does not '
f'comply with PEP 440: {version}'
) from e
except InvalidVersion:
tmpl = (
'Version loaded from {value} does not '
'comply with PEP 440: {version}'
)
raise DistutilsOptionError(tmpl.format(**locals()))
return version
@@ -601,18 +603,19 @@ class ConfigMetadataHandler(ConfigHandler["DistributionMetadata"]):
class ConfigOptionsHandler(ConfigHandler["Distribution"]):
section_prefix = 'options'
def __init__(
self,
target_obj: Distribution,
target_obj: "Distribution",
options: AllCommandOptions,
ignore_option_errors: bool,
ensure_discovered: expand.EnsurePackagesDiscovered,
) -> None:
):
super().__init__(target_obj, options, ignore_option_errors, ensure_discovered)
self.root_dir = target_obj.src_root
self.package_dir: dict[str, str] = {} # To be filled by `find_packages`
self.package_dir: Dict[str, str] = {} # To be filled by `find_packages`
@classmethod
def _parse_list_semicolon(cls, value):
@@ -627,20 +630,20 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
_warn_accidental_env_marker_misconfig(label, value, parsed)
# Filter it to only include lines that are not comments. `parse_list`
# will have stripped each line and filtered out empties.
return _static.List(line for line in parsed if not line.startswith("#"))
# ^-- Use `_static.List` to mark a non-`Dynamic` Core Metadata
return [line for line in parsed if not line.startswith("#")]
@property
def parsers(self):
"""Metadata item name to parser function mapping."""
parse_list = self._parse_list
parse_bool = self._parse_bool
parse_dict = self._parse_dict
parse_cmdclass = self._parse_cmdclass
return {
'zip_safe': parse_bool,
'include_package_data': parse_bool,
'package_dir': self._parse_dict,
'package_dir': parse_dict,
'scripts': parse_list,
'eager_resources': parse_list,
'dependency_links': parse_list,
@@ -648,16 +651,17 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
parse_list,
"The namespace_packages parameter is deprecated, "
"consider using implicit namespaces instead (PEP 420).",
# TODO: define due date, see setuptools.dist:check_nsp.
SetuptoolsDeprecationWarning,
),
'install_requires': partial( # Core Metadata
'install_requires': partial(
self._parse_requirements_list, "install_requires"
),
'setup_requires': self._parse_list_semicolon,
'tests_require': self._parse_list_semicolon,
'packages': self._parse_packages,
'entry_points': self._parse_file_in_root,
'py_modules': parse_list,
'python_requires': _static.SpecifierSet, # Core Metadata
'python_requires': SpecifierSet,
'cmdclass': parse_cmdclass,
}
@@ -700,7 +704,10 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
section_data = self._parse_section_to_dict(section_options, self._parse_list)
valid_keys = ['where', 'include', 'exclude']
find_kwargs = {k: v for k, v in section_data.items() if k in valid_keys and v}
find_kwargs = dict(
[(k, v) for k, v in section_data.items() if k in valid_keys and v]
)
where = find_kwargs.get('where')
if where is not None:
@@ -708,7 +715,7 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
return find_kwargs
def parse_section_entry_points(self, section_options) -> None:
def parse_section_entry_points(self, section_options):
"""Parses `entry_points` configuration file section.
:param dict section_options:
@@ -720,61 +727,36 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
package_data = self._parse_section_to_dict(section_options, self._parse_list)
return expand.canonic_package_data(package_data)
def parse_section_package_data(self, section_options) -> None:
def parse_section_package_data(self, section_options):
"""Parses `package_data` configuration file section.
:param dict section_options:
"""
self['package_data'] = self._parse_package_data(section_options)
def parse_section_exclude_package_data(self, section_options) -> None:
def parse_section_exclude_package_data(self, section_options):
"""Parses `exclude_package_data` configuration file section.
:param dict section_options:
"""
self['exclude_package_data'] = self._parse_package_data(section_options)
def parse_section_extras_require(self, section_options) -> None: # Core Metadata
def parse_section_extras_require(self, section_options):
"""Parses `extras_require` configuration file section.
:param dict section_options:
"""
parsed = self._parse_section_to_dict_with_key(
section_options,
lambda k, v: self._parse_requirements_list(f"extras_require[{k}]", v),
lambda k, v: self._parse_requirements_list(f"extras_require[{k}]", v)
)
self['extras_require'] = _static.Dict(parsed)
# ^-- Use `_static.Dict` to mark a non-`Dynamic` Core Metadata
self['extras_require'] = parsed
def parse_section_data_files(self, section_options) -> None:
def parse_section_data_files(self, section_options):
"""Parses `data_files` configuration file section.
:param dict section_options:
"""
parsed = self._parse_section_to_dict(section_options, self._parse_list)
self['data_files'] = expand.canonic_data_files(parsed, self.root_dir)
class _AmbiguousMarker(SetuptoolsDeprecationWarning):
_SUMMARY = "Ambiguous requirement marker."
_DETAILS = """
One of the parsed requirements in `{field}` looks like a valid environment marker:
{req!r}
Please make sure that the configuration file is correct.
You can use dangling lines to avoid this problem.
"""
_SEE_DOCS = "userguide/declarative_config.html#opt-2"
# TODO: should we include due_date here? Initially introduced in 6 Aug 2022.
# Does this make sense with latest version of packaging?
@classmethod
def message(cls, **kw):
docs = f"https://setuptools.pypa.io/en/latest/{cls._SEE_DOCS}"
return cls._format(cls._SUMMARY, cls._DETAILS, see_url=docs, format_args=kw)
class _DeprecatedConfig(SetuptoolsDeprecationWarning):
_SEE_DOCS = "userguide/declarative_config.html"