codekingpro/portable-devtools
114k
1"""2Load setuptools configuration from ``setup.cfg`` files.3 4**API will be made private in the future**5 6To read project metadata, consider using7``build.util.project_wheel_metadata`` (https://pypi.org/project/build/).8For simple scenarios, you can also try parsing the file directly9with the help of ``configparser``.10"""11 12import contextlib13import functools14import os15from collections import defaultdict16from functools import partial17from functools import wraps18from typing import (19 TYPE_CHECKING,20 Callable,21 Any,22 Dict,23 Generic,24 Iterable,25 List,26 Optional,27 Set,28 Tuple,29 TypeVar,30 Union,31)32 33from .._path import StrPath34from ..errors import FileError, OptionError35from ..extern.packaging.markers import default_environment as marker_env36from ..extern.packaging.requirements import InvalidRequirement, Requirement37from ..extern.packaging.specifiers import SpecifierSet38from ..extern.packaging.version import InvalidVersion, Version39from ..warnings import SetuptoolsDeprecationWarning40from . import expand41 42if TYPE_CHECKING:43 from distutils.dist import DistributionMetadata # noqa44 45 from setuptools.dist import Distribution # noqa46 47SingleCommandOptions = Dict["str", Tuple["str", Any]]48"""Dict that associate the name of the options of a particular command to a49tuple. The first element of the tuple indicates the origin of the option value50(e.g. the name of the configuration file where it was read from),51while the second element of the tuple is the option value itself52"""53AllCommandOptions = Dict["str", SingleCommandOptions] # cmd name => its options54Target = TypeVar("Target", bound=Union["Distribution", "DistributionMetadata"])55 56 57def read_configuration(58 filepath: StrPath, find_others=False, ignore_option_errors=False59) -> dict:60 """Read given configuration file and returns options from it as a dict.61 62 :param str|unicode filepath: Path to configuration file63 to get options from.64 65 :param bool find_others: Whether to search for other configuration files66 which could be on in various places.67 68 :param bool ignore_option_errors: Whether to silently ignore69 options, values of which could not be resolved (e.g. due to exceptions70 in directives such as file:, attr:, etc.).71 If False exceptions are propagated as expected.72 73 :rtype: dict74 """75 from setuptools.dist import Distribution76 77 dist = Distribution()78 filenames = dist.find_config_files() if find_others else []79 handlers = _apply(dist, filepath, filenames, ignore_option_errors)80 return configuration_to_dict(handlers)81 82 83def apply_configuration(dist: "Distribution", filepath: StrPath) -> "Distribution":84 """Apply the configuration from a ``setup.cfg`` file into an existing85 distribution object.86 """87 _apply(dist, filepath)88 dist._finalize_requires()89 return dist90 91 92def _apply(93 dist: "Distribution",94 filepath: StrPath,95 other_files: Iterable[StrPath] = (),96 ignore_option_errors: bool = False,97) -> Tuple["ConfigHandler", ...]:98 """Read configuration from ``filepath`` and applies to the ``dist`` object."""99 from setuptools.dist import _Distribution100 101 filepath = os.path.abspath(filepath)102 103 if not os.path.isfile(filepath):104 raise FileError(f'Configuration file {filepath} does not exist.')105 106 current_directory = os.getcwd()107 os.chdir(os.path.dirname(filepath))108 filenames = [*other_files, filepath]109 110 try:111 _Distribution.parse_config_files(dist, filenames=filenames) # type: ignore[arg-type] # TODO: fix in disutils stubs112 handlers = parse_configuration(113 dist, dist.command_options, ignore_option_errors=ignore_option_errors114 )115 dist._finalize_license_files()116 finally:117 os.chdir(current_directory)118 119 return handlers120 121 122def _get_option(target_obj: Target, key: str):123 """124 Given a target object and option key, get that option from125 the target object, either through a get_{key} method or126 from an attribute directly.127 """128 getter_name = f'get_{key}'129 by_attribute = functools.partial(getattr, target_obj, key)130 getter = getattr(target_obj, getter_name, by_attribute)131 return getter()132 133 134def configuration_to_dict(handlers: Tuple["ConfigHandler", ...]) -> dict:135 """Returns configuration data gathered by given handlers as a dict.136 137 :param list[ConfigHandler] handlers: Handlers list,138 usually from parse_configuration()139 140 :rtype: dict141 """142 config_dict: dict = defaultdict(dict)143 144 for handler in handlers:145 for option in handler.set_options:146 value = _get_option(handler.target_obj, option)147 config_dict[handler.section_prefix][option] = value148 149 return config_dict150 151 152def parse_configuration(153 distribution: "Distribution",154 command_options: AllCommandOptions,155 ignore_option_errors=False,156) -> Tuple["ConfigMetadataHandler", "ConfigOptionsHandler"]:157 """Performs additional parsing of configuration options158 for a distribution.159 160 Returns a list of used option handlers.161 162 :param Distribution distribution:163 :param dict command_options:164 :param bool ignore_option_errors: Whether to silently ignore165 options, values of which could not be resolved (e.g. due to exceptions166 in directives such as file:, attr:, etc.).167 If False exceptions are propagated as expected.168 :rtype: list169 """170 with expand.EnsurePackagesDiscovered(distribution) as ensure_discovered:171 options = ConfigOptionsHandler(172 distribution,173 command_options,174 ignore_option_errors,175 ensure_discovered,176 )177 178 options.parse()179 if not distribution.package_dir:180 distribution.package_dir = options.package_dir # Filled by `find_packages`181 182 meta = ConfigMetadataHandler(183 distribution.metadata,184 command_options,185 ignore_option_errors,186 ensure_discovered,187 distribution.package_dir,188 distribution.src_root,189 )190 meta.parse()191 distribution._referenced_files.update(192 options._referenced_files, meta._referenced_files193 )194 195 return meta, options196 197 198def _warn_accidental_env_marker_misconfig(label: str, orig_value: str, parsed: list):199 """Because users sometimes misinterpret this configuration:200 201 [options.extras_require]202 foo = bar;python_version<"4"203 204 It looks like one requirement with an environment marker205 but because there is no newline, it's parsed as two requirements206 with a semicolon as separator.207 208 Therefore, if:209 * input string does not contain a newline AND210 * parsed result contains two requirements AND211 * parsing of the two parts from the result ("<first>;<second>")212 leads in a valid Requirement with a valid marker213 a UserWarning is shown to inform the user about the possible problem.214 """215 if "\n" in orig_value or len(parsed) != 2:216 return217 218 markers = marker_env().keys()219 220 try:221 req = Requirement(parsed[1])222 if req.name in markers:223 _AmbiguousMarker.emit(field=label, req=parsed[1])224 except InvalidRequirement as ex:225 if any(parsed[1].startswith(marker) for marker in markers):226 msg = _AmbiguousMarker.message(field=label, req=parsed[1])227 raise InvalidRequirement(msg) from ex228 229 230class ConfigHandler(Generic[Target]):231 """Handles metadata supplied in configuration files."""232 233 section_prefix: str234 """Prefix for config sections handled by this handler.235 Must be provided by class heirs.236 237 """238 239 aliases: Dict[str, str] = {}240 """Options aliases.241 For compatibility with various packages. E.g.: d2to1 and pbr.242 Note: `-` in keys is replaced with `_` by config parser.243 244 """245 246 def __init__(247 self,248 target_obj: Target,249 options: AllCommandOptions,250 ignore_option_errors,251 ensure_discovered: expand.EnsurePackagesDiscovered,252 ):253 self.ignore_option_errors = ignore_option_errors254 self.target_obj = target_obj255 self.sections = dict(self._section_options(options))256 self.set_options: List[str] = []257 self.ensure_discovered = ensure_discovered258 self._referenced_files: Set[str] = set()259 """After parsing configurations, this property will enumerate260 all files referenced by the "file:" directive. Private API for setuptools only.261 """262 263 @classmethod264 def _section_options(cls, options: AllCommandOptions):265 for full_name, value in options.items():266 pre, sep, name = full_name.partition(cls.section_prefix)267 if pre:268 continue269 yield name.lstrip('.'), value270 271 @property272 def parsers(self):273 """Metadata item name to parser function mapping."""274 raise NotImplementedError(275 '%s must provide .parsers property' % self.__class__.__name__276 )277 278 def __setitem__(self, option_name, value):279 target_obj = self.target_obj280 281 # Translate alias into real name.282 option_name = self.aliases.get(option_name, option_name)283 284 try:285 current_value = getattr(target_obj, option_name)286 except AttributeError as e:287 raise KeyError(option_name) from e288 289 if current_value:290 # Already inhabited. Skipping.291 return292 293 try:294 parsed = self.parsers.get(option_name, lambda x: x)(value)295 except (Exception,) * self.ignore_option_errors:296 return297 298 simple_setter = functools.partial(target_obj.__setattr__, option_name)299 setter = getattr(target_obj, 'set_%s' % option_name, simple_setter)300 setter(parsed)301 302 self.set_options.append(option_name)303 304 @classmethod305 def _parse_list(cls, value, separator=','):306 """Represents value as a list.307 308 Value is split either by separator (defaults to comma) or by lines.309 310 :param value:311 :param separator: List items separator character.312 :rtype: list313 """314 if isinstance(value, list): # _get_parser_compound case315 return value316 317 if '\n' in value:318 value = value.splitlines()319 else:320 value = value.split(separator)321 322 return [chunk.strip() for chunk in value if chunk.strip()]323 324 @classmethod325 def _parse_dict(cls, value):326 """Represents value as a dict.327 328 :param value:329 :rtype: dict330 """331 separator = '='332 result = {}333 for line in cls._parse_list(value):334 key, sep, val = line.partition(separator)335 if sep != separator:336 raise OptionError(f"Unable to parse option value to dict: {value}")337 result[key.strip()] = val.strip()338 339 return result340 341 @classmethod342 def _parse_bool(cls, value):343 """Represents value as boolean.344 345 :param value:346 :rtype: bool347 """348 value = value.lower()349 return value in ('1', 'true', 'yes')350 351 @classmethod352 def _exclude_files_parser(cls, key):353 """Returns a parser function to make sure field inputs354 are not files.355 356 Parses a value after getting the key so error messages are357 more informative.358 359 :param key:360 :rtype: callable361 """362 363 def parser(value):364 exclude_directive = 'file:'365 if value.startswith(exclude_directive):366 raise ValueError(367 'Only strings are accepted for the {0} field, '368 'files are not accepted'.format(key)369 )370 return value371 372 return parser373 374 def _parse_file(self, value, root_dir: StrPath):375 """Represents value as a string, allowing including text376 from nearest files using `file:` directive.377 378 Directive is sandboxed and won't reach anything outside379 directory with setup.py.380 381 Examples:382 file: README.rst, CHANGELOG.md, src/file.txt383 384 :param str value:385 :rtype: str386 """387 include_directive = 'file:'388 389 if not isinstance(value, str):390 return value391 392 if not value.startswith(include_directive):393 return value394 395 spec = value[len(include_directive) :]396 filepaths = [path.strip() for path in spec.split(',')]397 self._referenced_files.update(filepaths)398 return expand.read_files(filepaths, root_dir)399 400 def _parse_attr(self, value, package_dir, root_dir: StrPath):401 """Represents value as a module attribute.402 403 Examples:404 attr: package.attr405 attr: package.module.attr406 407 :param str value:408 :rtype: str409 """410 attr_directive = 'attr:'411 if not value.startswith(attr_directive):412 return value413 414 attr_desc = value.replace(attr_directive, '')415 416 # Make sure package_dir is populated correctly, so `attr:` directives can work417 package_dir.update(self.ensure_discovered.package_dir)418 return expand.read_attr(attr_desc, package_dir, root_dir)419 420 @classmethod421 def _get_parser_compound(cls, *parse_methods):422 """Returns parser function to represents value as a list.423 424 Parses a value applying given methods one after another.425 426 :param parse_methods:427 :rtype: callable428 """429 430 def parse(value):431 parsed = value432 433 for method in parse_methods:434 parsed = method(parsed)435 436 return parsed437 438 return parse439 440 @classmethod441 def _parse_section_to_dict_with_key(cls, section_options, values_parser):442 """Parses section options into a dictionary.443 444 Applies a given parser to each option in a section.445 446 :param dict section_options:447 :param callable values_parser: function with 2 args corresponding to key, value448 :rtype: dict449 """450 value = {}451 for key, (_, val) in section_options.items():452 value[key] = values_parser(key, val)453 return value454 455 @classmethod456 def _parse_section_to_dict(cls, section_options, values_parser=None):457 """Parses section options into a dictionary.458 459 Optionally applies a given parser to each value.460 461 :param dict section_options:462 :param callable values_parser: function with 1 arg corresponding to option value463 :rtype: dict464 """465 parser = (lambda _, v: values_parser(v)) if values_parser else (lambda _, v: v)466 return cls._parse_section_to_dict_with_key(section_options, parser)467 468 def parse_section(self, section_options):469 """Parses configuration file section.470 471 :param dict section_options:472 """473 for name, (_, value) in section_options.items():474 with contextlib.suppress(KeyError):475 # Keep silent for a new option may appear anytime.476 self[name] = value477 478 def parse(self) -> None:479 """Parses configuration file items from one480 or more related sections.481 482 """483 for section_name, section_options in self.sections.items():484 method_postfix = ''485 if section_name: # [section.option] variant486 method_postfix = '_%s' % section_name487 488 section_parser_method: Optional[Callable] = getattr(489 self,490 # Dots in section names are translated into dunderscores.491 ('parse_section%s' % method_postfix).replace('.', '__'),492 None,493 )494 495 if section_parser_method is None:496 raise OptionError(497 "Unsupported distribution option section: "498 f"[{self.section_prefix}.{section_name}]"499 )500 501 section_parser_method(section_options)502 503 def _deprecated_config_handler(self, func, msg, **kw):504 """this function will wrap around parameters that are deprecated505 506 :param msg: deprecation message507 :param func: function to be wrapped around508 """509 510 @wraps(func)511 def config_handler(*args, **kwargs):512 kw.setdefault("stacklevel", 2)513 _DeprecatedConfig.emit("Deprecated config in `setup.cfg`", msg, **kw)514 return func(*args, **kwargs)515 516 return config_handler517 518 519class ConfigMetadataHandler(ConfigHandler["DistributionMetadata"]):520 section_prefix = 'metadata'521 522 aliases = {523 'home_page': 'url',524 'summary': 'description',525 'classifier': 'classifiers',526 'platform': 'platforms',527 }528 529 strict_mode = False530 """We need to keep it loose, to be partially compatible with531 `pbr` and `d2to1` packages which also uses `metadata` section.532 533 """534 535 def __init__(536 self,537 target_obj: "DistributionMetadata",538 options: AllCommandOptions,539 ignore_option_errors: bool,540 ensure_discovered: expand.EnsurePackagesDiscovered,541 package_dir: Optional[dict] = None,542 root_dir: StrPath = os.curdir,543 ):544 super().__init__(target_obj, options, ignore_option_errors, ensure_discovered)545 self.package_dir = package_dir546 self.root_dir = root_dir547 548 @property549 def parsers(self):550 """Metadata item name to parser function mapping."""551 parse_list = self._parse_list552 parse_file = partial(self._parse_file, root_dir=self.root_dir)553 parse_dict = self._parse_dict554 exclude_files_parser = self._exclude_files_parser555 556 return {557 'platforms': parse_list,558 'keywords': parse_list,559 'provides': parse_list,560 'obsoletes': parse_list,561 'classifiers': self._get_parser_compound(parse_file, parse_list),562 'license': exclude_files_parser('license'),563 'license_files': parse_list,564 'description': parse_file,565 'long_description': parse_file,566 'version': self._parse_version,567 'project_urls': parse_dict,568 }569 570 def _parse_version(self, value):571 """Parses `version` option value.572 573 :param value:574 :rtype: str575 576 """577 version = self._parse_file(value, self.root_dir)578 579 if version != value:580 version = version.strip()581 # Be strict about versions loaded from file because it's easy to582 # accidentally include newlines and other unintended content583 try:584 Version(version)585 except InvalidVersion as e:586 raise OptionError(587 f'Version loaded from {value} does not '588 f'comply with PEP 440: {version}'589 ) from e590 591 return version592 593 return expand.version(self._parse_attr(value, self.package_dir, self.root_dir))594 595 596class ConfigOptionsHandler(ConfigHandler["Distribution"]):597 section_prefix = 'options'598 599 def __init__(600 self,601 target_obj: "Distribution",602 options: AllCommandOptions,603 ignore_option_errors: bool,604 ensure_discovered: expand.EnsurePackagesDiscovered,605 ):606 super().__init__(target_obj, options, ignore_option_errors, ensure_discovered)607 self.root_dir = target_obj.src_root608 self.package_dir: Dict[str, str] = {} # To be filled by `find_packages`609 610 @classmethod611 def _parse_list_semicolon(cls, value):612 return cls._parse_list(value, separator=';')613 614 def _parse_file_in_root(self, value):615 return self._parse_file(value, root_dir=self.root_dir)616 617 def _parse_requirements_list(self, label: str, value: str):618 # Parse a requirements list, either by reading in a `file:`, or a list.619 parsed = self._parse_list_semicolon(self._parse_file_in_root(value))620 _warn_accidental_env_marker_misconfig(label, value, parsed)621 # Filter it to only include lines that are not comments. `parse_list`622 # will have stripped each line and filtered out empties.623 return [line for line in parsed if not line.startswith("#")]624 625 @property626 def parsers(self):627 """Metadata item name to parser function mapping."""628 parse_list = self._parse_list629 parse_bool = self._parse_bool630 parse_dict = self._parse_dict631 parse_cmdclass = self._parse_cmdclass632 633 return {634 'zip_safe': parse_bool,635 'include_package_data': parse_bool,636 'package_dir': parse_dict,637 'scripts': parse_list,638 'eager_resources': parse_list,639 'dependency_links': parse_list,640 'namespace_packages': self._deprecated_config_handler(641 parse_list,642 "The namespace_packages parameter is deprecated, "643 "consider using implicit namespaces instead (PEP 420).",644 # TODO: define due date, see setuptools.dist:check_nsp.645 ),646 'install_requires': partial(647 self._parse_requirements_list, "install_requires"648 ),649 'setup_requires': self._parse_list_semicolon,650 'tests_require': self._parse_list_semicolon,651 'packages': self._parse_packages,652 'entry_points': self._parse_file_in_root,653 'py_modules': parse_list,654 'python_requires': SpecifierSet,655 'cmdclass': parse_cmdclass,656 }657 658 def _parse_cmdclass(self, value):659 package_dir = self.ensure_discovered.package_dir660 return expand.cmdclass(self._parse_dict(value), package_dir, self.root_dir)661 662 def _parse_packages(self, value):663 """Parses `packages` option value.664 665 :param value:666 :rtype: list667 """668 find_directives = ['find:', 'find_namespace:']669 trimmed_value = value.strip()670 671 if trimmed_value not in find_directives:672 return self._parse_list(value)673 674 # Read function arguments from a dedicated section.675 find_kwargs = self.parse_section_packages__find(676 self.sections.get('packages.find', {})677 )678 679 find_kwargs.update(680 namespaces=(trimmed_value == find_directives[1]),681 root_dir=self.root_dir,682 fill_package_dir=self.package_dir,683 )684 685 return expand.find_packages(**find_kwargs)686 687 def parse_section_packages__find(self, section_options):688 """Parses `packages.find` configuration file section.689 690 To be used in conjunction with _parse_packages().691 692 :param dict section_options:693 """694 section_data = self._parse_section_to_dict(section_options, self._parse_list)695 696 valid_keys = ['where', 'include', 'exclude']697 698 find_kwargs = dict([699 (k, v) for k, v in section_data.items() if k in valid_keys and v700 ])701 702 where = find_kwargs.get('where')703 if where is not None:704 find_kwargs['where'] = where[0] # cast list to single val705 706 return find_kwargs707 708 def parse_section_entry_points(self, section_options):709 """Parses `entry_points` configuration file section.710 711 :param dict section_options:712 """713 parsed = self._parse_section_to_dict(section_options, self._parse_list)714 self['entry_points'] = parsed715 716 def _parse_package_data(self, section_options):717 package_data = self._parse_section_to_dict(section_options, self._parse_list)718 return expand.canonic_package_data(package_data)719 720 def parse_section_package_data(self, section_options):721 """Parses `package_data` configuration file section.722 723 :param dict section_options:724 """725 self['package_data'] = self._parse_package_data(section_options)726 727 def parse_section_exclude_package_data(self, section_options):728 """Parses `exclude_package_data` configuration file section.729 730 :param dict section_options:731 """732 self['exclude_package_data'] = self._parse_package_data(section_options)733 734 def parse_section_extras_require(self, section_options):735 """Parses `extras_require` configuration file section.736 737 :param dict section_options:738 """739 parsed = self._parse_section_to_dict_with_key(740 section_options,741 lambda k, v: self._parse_requirements_list(f"extras_require[{k}]", v),742 )743 744 self['extras_require'] = parsed745 746 def parse_section_data_files(self, section_options):747 """Parses `data_files` configuration file section.748 749 :param dict section_options:750 """751 parsed = self._parse_section_to_dict(section_options, self._parse_list)752 self['data_files'] = expand.canonic_data_files(parsed, self.root_dir)753 754 755class _AmbiguousMarker(SetuptoolsDeprecationWarning):756 _SUMMARY = "Ambiguous requirement marker."757 _DETAILS = """758 One of the parsed requirements in `{field}` looks like a valid environment marker:759 760 {req!r}761 762 Please make sure that the configuration file is correct.763 You can use dangling lines to avoid this problem.764 """765 _SEE_DOCS = "userguide/declarative_config.html#opt-2"766 # TODO: should we include due_date here? Initially introduced in 6 Aug 2022.767 # Does this make sense with latest version of packaging?768 769 @classmethod770 def message(cls, **kw):771 docs = f"https://setuptools.pypa.io/en/latest/{cls._SEE_DOCS}"772 return cls._format(cls._SUMMARY, cls._DETAILS, see_url=docs, format_args=kw)773 774 775class _DeprecatedConfig(SetuptoolsDeprecationWarning):776 _SEE_DOCS = "userguide/declarative_config.html"777 