Spaces:
Running
Running
""" | |
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``. | |
""" | |
import contextlib | |
import functools | |
import os | |
from collections import defaultdict | |
from functools import partial | |
from functools import wraps | |
from typing import ( | |
TYPE_CHECKING, | |
Callable, | |
Any, | |
Dict, | |
Generic, | |
Iterable, | |
List, | |
Optional, | |
Set, | |
Tuple, | |
TypeVar, | |
Union, | |
) | |
from ..errors import FileError, OptionError | |
from ..extern.packaging.markers import default_environment as marker_env | |
from ..extern.packaging.requirements import InvalidRequirement, Requirement | |
from ..extern.packaging.specifiers import SpecifierSet | |
from ..extern.packaging.version import InvalidVersion, Version | |
from ..warnings import SetuptoolsDeprecationWarning | |
from . import expand | |
if TYPE_CHECKING: | |
from distutils.dist import DistributionMetadata # noqa | |
from setuptools.dist import Distribution # noqa | |
_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 = Dict["str", SingleCommandOptions] # cmd name => its options | |
Target = TypeVar("Target", bound=Union["Distribution", "DistributionMetadata"]) | |
def read_configuration( | |
filepath: _Path, find_others=False, ignore_option_errors=False | |
) -> dict: | |
"""Read given configuration file and returns options from it as a dict. | |
:param str|unicode filepath: Path to configuration file | |
to get options from. | |
:param bool find_others: Whether to search for other configuration files | |
which could be on in various places. | |
:param bool ignore_option_errors: Whether to silently ignore | |
options, values of which could not be resolved (e.g. due to exceptions | |
in directives such as file:, attr:, etc.). | |
If False exceptions are propagated as expected. | |
:rtype: dict | |
""" | |
from setuptools.dist import Distribution | |
dist = Distribution() | |
filenames = dist.find_config_files() if find_others else [] | |
handlers = _apply(dist, filepath, filenames, ignore_option_errors) | |
return configuration_to_dict(handlers) | |
def apply_configuration(dist: "Distribution", filepath: _Path) -> "Distribution": | |
"""Apply the configuration from a ``setup.cfg`` file into an existing | |
distribution object. | |
""" | |
_apply(dist, filepath) | |
dist._finalize_requires() | |
return dist | |
def _apply( | |
dist: "Distribution", | |
filepath: _Path, | |
other_files: Iterable[_Path] = (), | |
ignore_option_errors: bool = False, | |
) -> 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.') | |
current_directory = os.getcwd() | |
os.chdir(os.path.dirname(filepath)) | |
filenames = [*other_files, filepath] | |
try: | |
_Distribution.parse_config_files(dist, filenames=filenames) | |
handlers = parse_configuration( | |
dist, dist.command_options, ignore_option_errors=ignore_option_errors | |
) | |
dist._finalize_license_files() | |
finally: | |
os.chdir(current_directory) | |
return handlers | |
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}' | |
by_attribute = functools.partial(getattr, target_obj, key) | |
getter = getattr(target_obj, getter_name, by_attribute) | |
return getter() | |
def configuration_to_dict(handlers: Tuple["ConfigHandler", ...]) -> dict: | |
"""Returns configuration data gathered by given handlers as a dict. | |
:param list[ConfigHandler] handlers: Handlers list, | |
usually from parse_configuration() | |
:rtype: dict | |
""" | |
config_dict: dict = defaultdict(dict) | |
for handler in handlers: | |
for option in handler.set_options: | |
value = _get_option(handler.target_obj, option) | |
config_dict[handler.section_prefix][option] = value | |
return config_dict | |
def parse_configuration( | |
distribution: "Distribution", | |
command_options: AllCommandOptions, | |
ignore_option_errors=False, | |
) -> Tuple["ConfigMetadataHandler", "ConfigOptionsHandler"]: | |
"""Performs additional parsing of configuration options | |
for a distribution. | |
Returns a list of used option handlers. | |
:param Distribution distribution: | |
:param dict command_options: | |
:param bool ignore_option_errors: Whether to silently ignore | |
options, values of which could not be resolved (e.g. due to exceptions | |
in directives such as file:, attr:, etc.). | |
If False exceptions are propagated as expected. | |
:rtype: list | |
""" | |
with expand.EnsurePackagesDiscovered(distribution) as ensure_discovered: | |
options = ConfigOptionsHandler( | |
distribution, | |
command_options, | |
ignore_option_errors, | |
ensure_discovered, | |
) | |
options.parse() | |
if not distribution.package_dir: | |
distribution.package_dir = options.package_dir # Filled by `find_packages` | |
meta = ConfigMetadataHandler( | |
distribution.metadata, | |
command_options, | |
ignore_option_errors, | |
ensure_discovered, | |
distribution.package_dir, | |
distribution.src_root, | |
) | |
meta.parse() | |
distribution._referenced_files.update( | |
options._referenced_files, meta._referenced_files | |
) | |
return meta, options | |
def _warn_accidental_env_marker_misconfig(label: str, orig_value: str, parsed: list): | |
"""Because users sometimes misinterpret this configuration: | |
[options.extras_require] | |
foo = bar;python_version<"4" | |
It looks like one requirement with an environment marker | |
but because there is no newline, it's parsed as two requirements | |
with a semicolon as separator. | |
Therefore, if: | |
* input string does not contain a newline AND | |
* parsed result contains two requirements AND | |
* parsing of the two parts from the result ("<first>;<second>") | |
leads in a valid Requirement with a valid marker | |
a UserWarning is shown to inform the user about the possible problem. | |
""" | |
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 | |
class ConfigHandler(Generic[Target]): | |
"""Handles metadata supplied in configuration files.""" | |
section_prefix: str | |
"""Prefix for config sections handled by this handler. | |
Must be provided by class heirs. | |
""" | |
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. | |
""" | |
def __init__( | |
self, | |
target_obj: Target, | |
options: AllCommandOptions, | |
ignore_option_errors, | |
ensure_discovered: expand.EnsurePackagesDiscovered, | |
): | |
self.ignore_option_errors = ignore_option_errors | |
self.target_obj = target_obj | |
self.sections = dict(self._section_options(options)) | |
self.set_options: List[str] = [] | |
self.ensure_discovered = ensure_discovered | |
self._referenced_files: Set[str] = set() | |
"""After parsing configurations, this property will enumerate | |
all files referenced by the "file:" directive. Private API for setuptools only. | |
""" | |
def _section_options(cls, options: AllCommandOptions): | |
for full_name, value in options.items(): | |
pre, sep, name = full_name.partition(cls.section_prefix) | |
if pre: | |
continue | |
yield name.lstrip('.'), value | |
def parsers(self): | |
"""Metadata item name to parser function mapping.""" | |
raise NotImplementedError( | |
'%s must provide .parsers property' % self.__class__.__name__ | |
) | |
def __setitem__(self, option_name, value): | |
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: | |
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: | |
return | |
simple_setter = functools.partial(target_obj.__setattr__, option_name) | |
setter = getattr(target_obj, 'set_%s' % option_name, simple_setter) | |
setter(parsed) | |
self.set_options.append(option_name) | |
def _parse_list(cls, value, separator=','): | |
"""Represents value as a list. | |
Value is split either by separator (defaults to comma) or by lines. | |
:param value: | |
:param separator: List items separator character. | |
:rtype: list | |
""" | |
if isinstance(value, list): # _get_parser_compound case | |
return value | |
if '\n' in value: | |
value = value.splitlines() | |
else: | |
value = value.split(separator) | |
return [chunk.strip() for chunk in value if chunk.strip()] | |
def _parse_dict(cls, value): | |
"""Represents value as a dict. | |
:param value: | |
:rtype: dict | |
""" | |
separator = '=' | |
result = {} | |
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}") | |
result[key.strip()] = val.strip() | |
return result | |
def _parse_bool(cls, value): | |
"""Represents value as boolean. | |
:param value: | |
:rtype: bool | |
""" | |
value = value.lower() | |
return value in ('1', 'true', 'yes') | |
def _exclude_files_parser(cls, key): | |
"""Returns a parser function to make sure field inputs | |
are not files. | |
Parses a value after getting the key so error messages are | |
more informative. | |
:param key: | |
:rtype: callable | |
""" | |
def parser(value): | |
exclude_directive = 'file:' | |
if value.startswith(exclude_directive): | |
raise ValueError( | |
'Only strings are accepted for the {0} field, ' | |
'files are not accepted'.format(key) | |
) | |
return value | |
return parser | |
def _parse_file(self, value, root_dir: _Path): | |
"""Represents value as a string, allowing including text | |
from nearest files using `file:` directive. | |
Directive is sandboxed and won't reach anything outside | |
directory with setup.py. | |
Examples: | |
file: README.rst, CHANGELOG.md, src/file.txt | |
:param str value: | |
:rtype: str | |
""" | |
include_directive = 'file:' | |
if not isinstance(value, str): | |
return value | |
if not value.startswith(include_directive): | |
return value | |
spec = value[len(include_directive) :] | |
filepaths = [path.strip() for path in spec.split(',')] | |
self._referenced_files.update(filepaths) | |
return expand.read_files(filepaths, root_dir) | |
def _parse_attr(self, value, package_dir, root_dir: _Path): | |
"""Represents value as a module attribute. | |
Examples: | |
attr: package.attr | |
attr: package.module.attr | |
:param str value: | |
:rtype: str | |
""" | |
attr_directive = 'attr:' | |
if not value.startswith(attr_directive): | |
return value | |
attr_desc = value.replace(attr_directive, '') | |
# Make sure package_dir is populated correctly, so `attr:` directives can work | |
package_dir.update(self.ensure_discovered.package_dir) | |
return expand.read_attr(attr_desc, package_dir, root_dir) | |
def _get_parser_compound(cls, *parse_methods): | |
"""Returns parser function to represents value as a list. | |
Parses a value applying given methods one after another. | |
:param parse_methods: | |
:rtype: callable | |
""" | |
def parse(value): | |
parsed = value | |
for method in parse_methods: | |
parsed = method(parsed) | |
return parsed | |
return parse | |
def _parse_section_to_dict_with_key(cls, section_options, values_parser): | |
"""Parses section options into a dictionary. | |
Applies a given parser to each option in a section. | |
:param dict section_options: | |
:param callable values_parser: function with 2 args corresponding to key, value | |
:rtype: dict | |
""" | |
value = {} | |
for key, (_, val) in section_options.items(): | |
value[key] = values_parser(key, val) | |
return value | |
def _parse_section_to_dict(cls, section_options, values_parser=None): | |
"""Parses section options into a dictionary. | |
Optionally applies a given parser to each value. | |
:param dict section_options: | |
:param callable values_parser: function with 1 arg corresponding to option value | |
:rtype: dict | |
""" | |
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): | |
"""Parses configuration file section. | |
:param dict section_options: | |
""" | |
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): | |
"""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 = '_%s' % section_name | |
section_parser_method: Optional[Callable] = getattr( | |
self, | |
# Dots in section names are translated into dunderscores. | |
('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}]" | |
) | |
section_parser_method(section_options) | |
def _deprecated_config_handler(self, func, msg, **kw): | |
"""this function will wrap around parameters that are deprecated | |
:param msg: deprecation message | |
:param func: function to be wrapped around | |
""" | |
def config_handler(*args, **kwargs): | |
kw.setdefault("stacklevel", 2) | |
_DeprecatedConfig.emit("Deprecated config in `setup.cfg`", msg, **kw) | |
return func(*args, **kwargs) | |
return config_handler | |
class ConfigMetadataHandler(ConfigHandler["DistributionMetadata"]): | |
section_prefix = 'metadata' | |
aliases = { | |
'home_page': 'url', | |
'summary': 'description', | |
'classifier': 'classifiers', | |
'platform': 'platforms', | |
} | |
strict_mode = False | |
"""We need to keep it loose, to be partially compatible with | |
`pbr` and `d2to1` packages which also uses `metadata` section. | |
""" | |
def __init__( | |
self, | |
target_obj: "DistributionMetadata", | |
options: AllCommandOptions, | |
ignore_option_errors: bool, | |
ensure_discovered: expand.EnsurePackagesDiscovered, | |
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 | |
def parsers(self): | |
"""Metadata item name to parser function mapping.""" | |
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 { | |
'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.", | |
due_date=(2023, 10, 30), | |
# Warning introduced in 27 Oct 2018 | |
), | |
'obsoletes': parse_list, | |
'classifiers': self._get_parser_compound(parse_file, parse_list), | |
'license': exclude_files_parser('license'), | |
'license_file': self._deprecated_config_handler( | |
exclude_files_parser('license_file'), | |
"The license_file parameter is deprecated, " | |
"use license_files instead.", | |
due_date=(2023, 10, 30), | |
# Warning introduced in 23 May 2021 | |
), | |
'license_files': parse_list, | |
'description': parse_file, | |
'long_description': parse_file, | |
'version': self._parse_version, | |
'project_urls': parse_dict, | |
} | |
def _parse_version(self, value): | |
"""Parses `version` option value. | |
:param value: | |
:rtype: str | |
""" | |
version = self._parse_file(value, self.root_dir) | |
if version != value: | |
version = version.strip() | |
# Be strict about versions loaded from file because it's easy to | |
# accidentally include newlines and other unintended content | |
try: | |
Version(version) | |
except InvalidVersion: | |
raise OptionError( | |
f'Version loaded from {value} does not ' | |
f'comply with PEP 440: {version}' | |
) | |
return version | |
return expand.version(self._parse_attr(value, self.package_dir, self.root_dir)) | |
class ConfigOptionsHandler(ConfigHandler["Distribution"]): | |
section_prefix = 'options' | |
def __init__( | |
self, | |
target_obj: "Distribution", | |
options: AllCommandOptions, | |
ignore_option_errors: bool, | |
ensure_discovered: expand.EnsurePackagesDiscovered, | |
): | |
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` | |
def _parse_list_semicolon(cls, value): | |
return cls._parse_list(value, separator=';') | |
def _parse_file_in_root(self, value): | |
return self._parse_file(value, root_dir=self.root_dir) | |
def _parse_requirements_list(self, label: str, value: str): | |
# Parse a requirements list, either by reading in a `file:`, or a list. | |
parsed = self._parse_list_semicolon(self._parse_file_in_root(value)) | |
_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 [line for line in parsed if not line.startswith("#")] | |
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': parse_dict, | |
'scripts': parse_list, | |
'eager_resources': parse_list, | |
'dependency_links': parse_list, | |
'namespace_packages': self._deprecated_config_handler( | |
parse_list, | |
"The namespace_packages parameter is deprecated, " | |
"consider using implicit namespaces instead (PEP 420).", | |
# TODO: define due date, see setuptools.dist:check_nsp. | |
), | |
'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': SpecifierSet, | |
'cmdclass': parse_cmdclass, | |
} | |
def _parse_cmdclass(self, value): | |
package_dir = self.ensure_discovered.package_dir | |
return expand.cmdclass(self._parse_dict(value), package_dir, self.root_dir) | |
def _parse_packages(self, value): | |
"""Parses `packages` option value. | |
:param value: | |
:rtype: list | |
""" | |
find_directives = ['find:', 'find_namespace:'] | |
trimmed_value = value.strip() | |
if trimmed_value not in find_directives: | |
return self._parse_list(value) | |
# Read function arguments from a dedicated section. | |
find_kwargs = self.parse_section_packages__find( | |
self.sections.get('packages.find', {}) | |
) | |
find_kwargs.update( | |
namespaces=(trimmed_value == find_directives[1]), | |
root_dir=self.root_dir, | |
fill_package_dir=self.package_dir, | |
) | |
return expand.find_packages(**find_kwargs) | |
def parse_section_packages__find(self, section_options): | |
"""Parses `packages.find` configuration file section. | |
To be used in conjunction with _parse_packages(). | |
:param dict section_options: | |
""" | |
section_data = self._parse_section_to_dict(section_options, self._parse_list) | |
valid_keys = ['where', 'include', 'exclude'] | |
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: | |
find_kwargs['where'] = where[0] # cast list to single val | |
return find_kwargs | |
def parse_section_entry_points(self, section_options): | |
"""Parses `entry_points` configuration file section. | |
:param dict section_options: | |
""" | |
parsed = self._parse_section_to_dict(section_options, self._parse_list) | |
self['entry_points'] = parsed | |
def _parse_package_data(self, section_options): | |
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): | |
"""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): | |
"""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): | |
"""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), | |
) | |
self['extras_require'] = parsed | |
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? | |
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" | |