import os
import contextlib
import functools
import warnings
from collections import defaultdict
from functools import partial
from functools import wraps
from typing import (TYPE_CHECKING, Callable, Any, Dict, Generic, Iterable, List,
Optional, Tuple, TypeVar, Union)
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 expand
if TYPE_CHECKING:
from setuptools.dist import Distribution from distutils.dist import DistributionMetadata
_Path = Union[str, os.PathLike]
SingleCommandOptions = Dict["str", Tuple["str", Any]]
AllCommandOptions = Dict["str", SingleCommandOptions] Target = TypeVar("Target", bound=Union["Distribution", "DistributionMetadata"])
def read_configuration(
filepath: _Path,
find_others=False,
ignore_option_errors=False
) -> 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(dist, filepath)
dist._finalize_requires()
return dist
def _apply(
dist: "Distribution", filepath: _Path,
other_files: Iterable[_Path] = (),
ignore_option_errors: bool = False,
) -> Tuple["ConfigHandler", ...]:
from setuptools.dist import _Distribution
filepath = os.path.abspath(filepath)
if not os.path.isfile(filepath):
raise DistutilsFileError('Configuration file %s does not exist.' % filepath)
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):
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: Tuple["ConfigHandler", ...]) -> 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"]:
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
meta = ConfigMetadataHandler(
distribution.metadata,
command_options,
ignore_option_errors,
ensure_discovered,
distribution.package_dir,
distribution.src_root,
)
meta.parse()
return meta, options
def _warn_accidental_env_marker_misconfig(label: str, orig_value: str, parsed: list):
if "\n" in orig_value or len(parsed) != 2:
return
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" )
warnings.warn(msg, UserWarning)
class ConfigHandler(Generic[Target]):
section_prefix: str
aliases: Dict[str, str] = {}
def __init__(
self,
target_obj: Target,
options: AllCommandOptions,
ignore_option_errors,
ensure_discovered: expand.EnsurePackagesDiscovered,
):
sections: AllCommandOptions = {}
section_prefix = self.section_prefix
for section_name, section_options in options.items():
if not section_name.startswith(section_prefix):
continue
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):
raise NotImplementedError(
'%s must provide .parsers property' % self.__class__.__name__
)
def __setitem__(self, option_name, value):
unknown = tuple()
target_obj = self.target_obj
option_name = self.aliases.get(option_name, option_name)
current_value = getattr(target_obj, option_name, unknown)
if current_value is unknown:
raise KeyError(option_name)
if current_value:
return
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
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)
@classmethod
def _parse_list(cls, value, separator=','):
if isinstance(value, list): return value
if '\n' in value:
value = value.splitlines()
else:
value = value.split(separator)
return [chunk.strip() for chunk in value if chunk.strip()]
@classmethod
def _parse_dict(cls, value):
separator = '='
result = {}
for line in cls._parse_list(value):
key, sep, val = line.partition(separator)
if sep != separator:
raise DistutilsOptionError(
'Unable to parse option value to dict: %s' % value
)
result[key.strip()] = val.strip()
return result
@classmethod
def _parse_bool(cls, value):
value = value.lower()
return value in ('1', 'true', 'yes')
@classmethod
def _exclude_files_parser(cls, key):
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
@classmethod
def _parse_file(cls, value, root_dir: _Path):
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(','))
return expand.read_files(filepaths, root_dir)
def _parse_attr(self, value, package_dir, root_dir: _Path):
attr_directive = 'attr:'
if not value.startswith(attr_directive):
return value
attr_desc = value.replace(attr_directive, '')
package_dir.update(self.ensure_discovered.package_dir)
return expand.read_attr(attr_desc, package_dir, root_dir)
@classmethod
def _get_parser_compound(cls, *parse_methods):
def parse(value):
parsed = value
for method in parse_methods:
parsed = method(parsed)
return parsed
return parse
@classmethod
def _parse_section_to_dict_with_key(cls, section_options, values_parser):
value = {}
for key, (_, val) in section_options.items():
value[key] = values_parser(key, val)
return value
@classmethod
def _parse_section_to_dict(cls, section_options, values_parser=None):
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):
for (name, (_, value)) in section_options.items():
with contextlib.suppress(KeyError):
self[name] = value
def parse(self):
for section_name, section_options in self.sections.items():
method_postfix = ''
if section_name: method_postfix = '_%s' % section_name
section_parser_method: Optional[Callable] = getattr(
self,
('parse_section%s' % method_postfix).replace('.', '__'),
None,
)
if section_parser_method is None:
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, warning_class):
@wraps(func)
def config_handler(*args, **kwargs):
warnings.warn(msg, warning_class)
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
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
@property
def parsers(self):
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.",
SetuptoolsDeprecationWarning,
),
'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.",
SetuptoolsDeprecationWarning,
),
'license_files': parse_list,
'description': parse_file,
'long_description': parse_file,
'version': self._parse_version,
'project_urls': parse_dict,
}
def _parse_version(self, value):
version = self._parse_file(value, self.root_dir)
if version != value:
version = version.strip()
try:
Version(version)
except InvalidVersion:
tmpl = (
'Version loaded from {value} does not '
'comply with PEP 440: {version}'
)
raise DistutilsOptionError(tmpl.format(**locals()))
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] = {}
@classmethod
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):
parsed = self._parse_list_semicolon(self._parse_file_in_root(value))
_warn_accidental_env_marker_misconfig(label, value, parsed)
return [line for line in parsed if not line.startswith("#")]
@property
def parsers(self):
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).",
SetuptoolsDeprecationWarning,
),
'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):
find_directives = ['find:', 'find_namespace:']
trimmed_value = value.strip()
if trimmed_value not in find_directives:
return self._parse_list(value)
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):
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]
return find_kwargs
def parse_section_entry_points(self, 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):
self['package_data'] = self._parse_package_data(section_options)
def parse_section_exclude_package_data(self, section_options):
self['exclude_package_data'] = self._parse_package_data(section_options)
def parse_section_extras_require(self, 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):
parsed = self._parse_section_to_dict(section_options, self._parse_list)
self['data_files'] = expand.canonic_data_files(parsed, self.root_dir)