1"""
2Load setuptools configuration from ``setup.cfg`` files.
3
4**API will be made private in the future**
5
6To read project metadata, consider using
7``build.util.project_wheel_metadata`` (https://pypi.org/project/build/).
8For simple scenarios, you can also try parsing the file directly
9with the help of ``configparser``.
10"""
11
12from __future__ import annotations
13
14import contextlib
15import functools
16import os
17from collections import defaultdict
18from functools import partial, wraps
19from typing import (
20 TYPE_CHECKING,
21 Any,
22 Callable,
23 Dict,
24 Generic,
25 Iterable,
26 Iterator,
27 List,
28 Tuple,
29 TypeVar,
30 Union,
31 cast,
32)
33
34from packaging.markers import default_environment as marker_env
35from packaging.requirements import InvalidRequirement, Requirement
36from packaging.specifiers import SpecifierSet
37from packaging.version import InvalidVersion, Version
38
39from .._path import StrPath
40from ..errors import FileError, OptionError
41from ..warnings import SetuptoolsDeprecationWarning
42from . import expand
43
44if TYPE_CHECKING:
45 from setuptools.dist import Distribution
46
47 from distutils.dist import DistributionMetadata
48
49SingleCommandOptions = Dict["str", Tuple["str", Any]]
50"""Dict that associate the name of the options of a particular command to a
51tuple. The first element of the tuple indicates the origin of the option value
52(e.g. the name of the configuration file where it was read from),
53while the second element of the tuple is the option value itself
54"""
55AllCommandOptions = Dict["str", SingleCommandOptions] # cmd name => its options
56Target = TypeVar("Target", bound=Union["Distribution", "DistributionMetadata"])
57
58
59def read_configuration(
60 filepath: StrPath, find_others=False, ignore_option_errors=False
61) -> dict:
62 """Read given configuration file and returns options from it as a dict.
63
64 :param str|unicode filepath: Path to configuration file
65 to get options from.
66
67 :param bool find_others: Whether to search for other configuration files
68 which could be on in various places.
69
70 :param bool ignore_option_errors: Whether to silently ignore
71 options, values of which could not be resolved (e.g. due to exceptions
72 in directives such as file:, attr:, etc.).
73 If False exceptions are propagated as expected.
74
75 :rtype: dict
76 """
77 from setuptools.dist import Distribution
78
79 dist = Distribution()
80 filenames = dist.find_config_files() if find_others else []
81 handlers = _apply(dist, filepath, filenames, ignore_option_errors)
82 return configuration_to_dict(handlers)
83
84
85def apply_configuration(dist: Distribution, filepath: StrPath) -> Distribution:
86 """Apply the configuration from a ``setup.cfg`` file into an existing
87 distribution object.
88 """
89 _apply(dist, filepath)
90 dist._finalize_requires()
91 return dist
92
93
94def _apply(
95 dist: Distribution,
96 filepath: StrPath,
97 other_files: Iterable[StrPath] = (),
98 ignore_option_errors: bool = False,
99) -> tuple[ConfigHandler, ...]:
100 """Read configuration from ``filepath`` and applies to the ``dist`` object."""
101 from setuptools.dist import _Distribution
102
103 filepath = os.path.abspath(filepath)
104
105 if not os.path.isfile(filepath):
106 raise FileError(f'Configuration file {filepath} does not exist.')
107
108 current_directory = os.getcwd()
109 os.chdir(os.path.dirname(filepath))
110 filenames = [*other_files, filepath]
111
112 try:
113 # TODO: Temporary cast until mypy 1.12 is released with upstream fixes from typeshed
114 _Distribution.parse_config_files(dist, filenames=cast(List[str], filenames))
115 handlers = parse_configuration(
116 dist, dist.command_options, ignore_option_errors=ignore_option_errors
117 )
118 dist._finalize_license_files()
119 finally:
120 os.chdir(current_directory)
121
122 return handlers
123
124
125def _get_option(target_obj: Target, key: str):
126 """
127 Given a target object and option key, get that option from
128 the target object, either through a get_{key} method or
129 from an attribute directly.
130 """
131 getter_name = f'get_{key}'
132 by_attribute = functools.partial(getattr, target_obj, key)
133 getter = getattr(target_obj, getter_name, by_attribute)
134 return getter()
135
136
137def configuration_to_dict(handlers: tuple[ConfigHandler, ...]) -> dict:
138 """Returns configuration data gathered by given handlers as a dict.
139
140 :param list[ConfigHandler] handlers: Handlers list,
141 usually from parse_configuration()
142
143 :rtype: dict
144 """
145 config_dict: dict = defaultdict(dict)
146
147 for handler in handlers:
148 for option in handler.set_options:
149 value = _get_option(handler.target_obj, option)
150 config_dict[handler.section_prefix][option] = value
151
152 return config_dict
153
154
155def parse_configuration(
156 distribution: Distribution,
157 command_options: AllCommandOptions,
158 ignore_option_errors=False,
159) -> tuple[ConfigMetadataHandler, ConfigOptionsHandler]:
160 """Performs additional parsing of configuration options
161 for a distribution.
162
163 Returns a list of used option handlers.
164
165 :param Distribution distribution:
166 :param dict command_options:
167 :param bool ignore_option_errors: Whether to silently ignore
168 options, values of which could not be resolved (e.g. due to exceptions
169 in directives such as file:, attr:, etc.).
170 If False exceptions are propagated as expected.
171 :rtype: list
172 """
173 with expand.EnsurePackagesDiscovered(distribution) as ensure_discovered:
174 options = ConfigOptionsHandler(
175 distribution,
176 command_options,
177 ignore_option_errors,
178 ensure_discovered,
179 )
180
181 options.parse()
182 if not distribution.package_dir:
183 distribution.package_dir = options.package_dir # Filled by `find_packages`
184
185 meta = ConfigMetadataHandler(
186 distribution.metadata,
187 command_options,
188 ignore_option_errors,
189 ensure_discovered,
190 distribution.package_dir,
191 distribution.src_root,
192 )
193 meta.parse()
194 distribution._referenced_files.update(
195 options._referenced_files, meta._referenced_files
196 )
197
198 return meta, options
199
200
201def _warn_accidental_env_marker_misconfig(label: str, orig_value: str, parsed: list):
202 """Because users sometimes misinterpret this configuration:
203
204 [options.extras_require]
205 foo = bar;python_version<"4"
206
207 It looks like one requirement with an environment marker
208 but because there is no newline, it's parsed as two requirements
209 with a semicolon as separator.
210
211 Therefore, if:
212 * input string does not contain a newline AND
213 * parsed result contains two requirements AND
214 * parsing of the two parts from the result ("<first>;<second>")
215 leads in a valid Requirement with a valid marker
216 a UserWarning is shown to inform the user about the possible problem.
217 """
218 if "\n" in orig_value or len(parsed) != 2:
219 return
220
221 markers = marker_env().keys()
222
223 try:
224 req = Requirement(parsed[1])
225 if req.name in markers:
226 _AmbiguousMarker.emit(field=label, req=parsed[1])
227 except InvalidRequirement as ex:
228 if any(parsed[1].startswith(marker) for marker in markers):
229 msg = _AmbiguousMarker.message(field=label, req=parsed[1])
230 raise InvalidRequirement(msg) from ex
231
232
233class ConfigHandler(Generic[Target]):
234 """Handles metadata supplied in configuration files."""
235
236 section_prefix: str
237 """Prefix for config sections handled by this handler.
238 Must be provided by class heirs.
239
240 """
241
242 aliases: dict[str, str] = {}
243 """Options aliases.
244 For compatibility with various packages. E.g.: d2to1 and pbr.
245 Note: `-` in keys is replaced with `_` by config parser.
246
247 """
248
249 def __init__(
250 self,
251 target_obj: Target,
252 options: AllCommandOptions,
253 ignore_option_errors,
254 ensure_discovered: expand.EnsurePackagesDiscovered,
255 ):
256 self.ignore_option_errors = ignore_option_errors
257 self.target_obj = target_obj
258 self.sections = dict(self._section_options(options))
259 self.set_options: list[str] = []
260 self.ensure_discovered = ensure_discovered
261 self._referenced_files: set[str] = set()
262 """After parsing configurations, this property will enumerate
263 all files referenced by the "file:" directive. Private API for setuptools only.
264 """
265
266 @classmethod
267 def _section_options(
268 cls, options: AllCommandOptions
269 ) -> Iterator[tuple[str, SingleCommandOptions]]:
270 for full_name, value in options.items():
271 pre, sep, name = full_name.partition(cls.section_prefix)
272 if pre:
273 continue
274 yield name.lstrip('.'), value
275
276 @property
277 def parsers(self):
278 """Metadata item name to parser function mapping."""
279 raise NotImplementedError(
280 '%s must provide .parsers property' % self.__class__.__name__
281 )
282
283 def __setitem__(self, option_name, value) -> None:
284 target_obj = self.target_obj
285
286 # Translate alias into real name.
287 option_name = self.aliases.get(option_name, option_name)
288
289 try:
290 current_value = getattr(target_obj, option_name)
291 except AttributeError as e:
292 raise KeyError(option_name) from e
293
294 if current_value:
295 # Already inhabited. Skipping.
296 return
297
298 try:
299 parsed = self.parsers.get(option_name, lambda x: x)(value)
300 except (Exception,) * self.ignore_option_errors:
301 return
302
303 simple_setter = functools.partial(target_obj.__setattr__, option_name)
304 setter = getattr(target_obj, 'set_%s' % option_name, simple_setter)
305 setter(parsed)
306
307 self.set_options.append(option_name)
308
309 @classmethod
310 def _parse_list(cls, value, separator=','):
311 """Represents value as a list.
312
313 Value is split either by separator (defaults to comma) or by lines.
314
315 :param value:
316 :param separator: List items separator character.
317 :rtype: list
318 """
319 if isinstance(value, list): # _get_parser_compound case
320 return value
321
322 if '\n' in value:
323 value = value.splitlines()
324 else:
325 value = value.split(separator)
326
327 return [chunk.strip() for chunk in value if chunk.strip()]
328
329 @classmethod
330 def _parse_dict(cls, value):
331 """Represents value as a dict.
332
333 :param value:
334 :rtype: dict
335 """
336 separator = '='
337 result = {}
338 for line in cls._parse_list(value):
339 key, sep, val = line.partition(separator)
340 if sep != separator:
341 raise OptionError(f"Unable to parse option value to dict: {value}")
342 result[key.strip()] = val.strip()
343
344 return result
345
346 @classmethod
347 def _parse_bool(cls, value):
348 """Represents value as boolean.
349
350 :param value:
351 :rtype: bool
352 """
353 value = value.lower()
354 return value in ('1', 'true', 'yes')
355
356 @classmethod
357 def _exclude_files_parser(cls, key):
358 """Returns a parser function to make sure field inputs
359 are not files.
360
361 Parses a value after getting the key so error messages are
362 more informative.
363
364 :param key:
365 :rtype: callable
366 """
367
368 def parser(value):
369 exclude_directive = 'file:'
370 if value.startswith(exclude_directive):
371 raise ValueError(
372 'Only strings are accepted for the {0} field, '
373 'files are not accepted'.format(key)
374 )
375 return value
376
377 return parser
378
379 def _parse_file(self, value, root_dir: StrPath):
380 """Represents value as a string, allowing including text
381 from nearest files using `file:` directive.
382
383 Directive is sandboxed and won't reach anything outside
384 directory with setup.py.
385
386 Examples:
387 file: README.rst, CHANGELOG.md, src/file.txt
388
389 :param str value:
390 :rtype: str
391 """
392 include_directive = 'file:'
393
394 if not isinstance(value, str):
395 return value
396
397 if not value.startswith(include_directive):
398 return value
399
400 spec = value[len(include_directive) :]
401 filepaths = [path.strip() for path in spec.split(',')]
402 self._referenced_files.update(filepaths)
403 return expand.read_files(filepaths, root_dir)
404
405 def _parse_attr(self, value, package_dir, root_dir: StrPath):
406 """Represents value as a module attribute.
407
408 Examples:
409 attr: package.attr
410 attr: package.module.attr
411
412 :param str value:
413 :rtype: str
414 """
415 attr_directive = 'attr:'
416 if not value.startswith(attr_directive):
417 return value
418
419 attr_desc = value.replace(attr_directive, '')
420
421 # Make sure package_dir is populated correctly, so `attr:` directives can work
422 package_dir.update(self.ensure_discovered.package_dir)
423 return expand.read_attr(attr_desc, package_dir, root_dir)
424
425 @classmethod
426 def _get_parser_compound(cls, *parse_methods):
427 """Returns parser function to represents value as a list.
428
429 Parses a value applying given methods one after another.
430
431 :param parse_methods:
432 :rtype: callable
433 """
434
435 def parse(value):
436 parsed = value
437
438 for method in parse_methods:
439 parsed = method(parsed)
440
441 return parsed
442
443 return parse
444
445 @classmethod
446 def _parse_section_to_dict_with_key(cls, section_options, values_parser):
447 """Parses section options into a dictionary.
448
449 Applies a given parser to each option in a section.
450
451 :param dict section_options:
452 :param callable values_parser: function with 2 args corresponding to key, value
453 :rtype: dict
454 """
455 value = {}
456 for key, (_, val) in section_options.items():
457 value[key] = values_parser(key, val)
458 return value
459
460 @classmethod
461 def _parse_section_to_dict(cls, section_options, values_parser=None):
462 """Parses section options into a dictionary.
463
464 Optionally applies a given parser to each value.
465
466 :param dict section_options:
467 :param callable values_parser: function with 1 arg corresponding to option value
468 :rtype: dict
469 """
470 parser = (lambda _, v: values_parser(v)) if values_parser else (lambda _, v: v)
471 return cls._parse_section_to_dict_with_key(section_options, parser)
472
473 def parse_section(self, section_options):
474 """Parses configuration file section.
475
476 :param dict section_options:
477 """
478 for name, (_, value) in section_options.items():
479 with contextlib.suppress(KeyError):
480 # Keep silent for a new option may appear anytime.
481 self[name] = value
482
483 def parse(self) -> None:
484 """Parses configuration file items from one
485 or more related sections.
486
487 """
488 for section_name, section_options in self.sections.items():
489 method_postfix = ''
490 if section_name: # [section.option] variant
491 method_postfix = '_%s' % section_name
492
493 section_parser_method: Callable | None = getattr(
494 self,
495 # Dots in section names are translated into dunderscores.
496 ('parse_section%s' % method_postfix).replace('.', '__'),
497 None,
498 )
499
500 if section_parser_method is None:
501 raise OptionError(
502 "Unsupported distribution option section: "
503 f"[{self.section_prefix}.{section_name}]"
504 )
505
506 section_parser_method(section_options)
507
508 def _deprecated_config_handler(self, func, msg, **kw):
509 """this function will wrap around parameters that are deprecated
510
511 :param msg: deprecation message
512 :param func: function to be wrapped around
513 """
514
515 @wraps(func)
516 def config_handler(*args, **kwargs):
517 kw.setdefault("stacklevel", 2)
518 _DeprecatedConfig.emit("Deprecated config in `setup.cfg`", msg, **kw)
519 return func(*args, **kwargs)
520
521 return config_handler
522
523
524class ConfigMetadataHandler(ConfigHandler["DistributionMetadata"]):
525 section_prefix = 'metadata'
526
527 aliases = {
528 'home_page': 'url',
529 'summary': 'description',
530 'classifier': 'classifiers',
531 'platform': 'platforms',
532 }
533
534 strict_mode = False
535 """We need to keep it loose, to be partially compatible with
536 `pbr` and `d2to1` packages which also uses `metadata` section.
537
538 """
539
540 def __init__(
541 self,
542 target_obj: DistributionMetadata,
543 options: AllCommandOptions,
544 ignore_option_errors: bool,
545 ensure_discovered: expand.EnsurePackagesDiscovered,
546 package_dir: dict | None = None,
547 root_dir: StrPath = os.curdir,
548 ):
549 super().__init__(target_obj, options, ignore_option_errors, ensure_discovered)
550 self.package_dir = package_dir
551 self.root_dir = root_dir
552
553 @property
554 def parsers(self):
555 """Metadata item name to parser function mapping."""
556 parse_list = self._parse_list
557 parse_file = partial(self._parse_file, root_dir=self.root_dir)
558 parse_dict = self._parse_dict
559 exclude_files_parser = self._exclude_files_parser
560
561 return {
562 'platforms': parse_list,
563 'keywords': parse_list,
564 'provides': parse_list,
565 'obsoletes': parse_list,
566 'classifiers': self._get_parser_compound(parse_file, parse_list),
567 'license': exclude_files_parser('license'),
568 'license_files': parse_list,
569 'description': parse_file,
570 'long_description': parse_file,
571 'version': self._parse_version,
572 'project_urls': parse_dict,
573 }
574
575 def _parse_version(self, value):
576 """Parses `version` option value.
577
578 :param value:
579 :rtype: str
580
581 """
582 version = self._parse_file(value, self.root_dir)
583
584 if version != value:
585 version = version.strip()
586 # Be strict about versions loaded from file because it's easy to
587 # accidentally include newlines and other unintended content
588 try:
589 Version(version)
590 except InvalidVersion as e:
591 raise OptionError(
592 f'Version loaded from {value} does not '
593 f'comply with PEP 440: {version}'
594 ) from e
595
596 return version
597
598 return expand.version(self._parse_attr(value, self.package_dir, self.root_dir))
599
600
601class ConfigOptionsHandler(ConfigHandler["Distribution"]):
602 section_prefix = 'options'
603
604 def __init__(
605 self,
606 target_obj: Distribution,
607 options: AllCommandOptions,
608 ignore_option_errors: bool,
609 ensure_discovered: expand.EnsurePackagesDiscovered,
610 ):
611 super().__init__(target_obj, options, ignore_option_errors, ensure_discovered)
612 self.root_dir = target_obj.src_root
613 self.package_dir: dict[str, str] = {} # To be filled by `find_packages`
614
615 @classmethod
616 def _parse_list_semicolon(cls, value):
617 return cls._parse_list(value, separator=';')
618
619 def _parse_file_in_root(self, value):
620 return self._parse_file(value, root_dir=self.root_dir)
621
622 def _parse_requirements_list(self, label: str, value: str):
623 # Parse a requirements list, either by reading in a `file:`, or a list.
624 parsed = self._parse_list_semicolon(self._parse_file_in_root(value))
625 _warn_accidental_env_marker_misconfig(label, value, parsed)
626 # Filter it to only include lines that are not comments. `parse_list`
627 # will have stripped each line and filtered out empties.
628 return [line for line in parsed if not line.startswith("#")]
629
630 @property
631 def parsers(self):
632 """Metadata item name to parser function mapping."""
633 parse_list = self._parse_list
634 parse_bool = self._parse_bool
635 parse_dict = self._parse_dict
636 parse_cmdclass = self._parse_cmdclass
637
638 return {
639 'zip_safe': parse_bool,
640 'include_package_data': parse_bool,
641 'package_dir': parse_dict,
642 'scripts': parse_list,
643 'eager_resources': parse_list,
644 'dependency_links': parse_list,
645 'namespace_packages': self._deprecated_config_handler(
646 parse_list,
647 "The namespace_packages parameter is deprecated, "
648 "consider using implicit namespaces instead (PEP 420).",
649 # TODO: define due date, see setuptools.dist:check_nsp.
650 ),
651 'install_requires': partial(
652 self._parse_requirements_list, "install_requires"
653 ),
654 'setup_requires': self._parse_list_semicolon,
655 'packages': self._parse_packages,
656 'entry_points': self._parse_file_in_root,
657 'py_modules': parse_list,
658 'python_requires': SpecifierSet,
659 'cmdclass': parse_cmdclass,
660 }
661
662 def _parse_cmdclass(self, value):
663 package_dir = self.ensure_discovered.package_dir
664 return expand.cmdclass(self._parse_dict(value), package_dir, self.root_dir)
665
666 def _parse_packages(self, value):
667 """Parses `packages` option value.
668
669 :param value:
670 :rtype: list
671 """
672 find_directives = ['find:', 'find_namespace:']
673 trimmed_value = value.strip()
674
675 if trimmed_value not in find_directives:
676 return self._parse_list(value)
677
678 # Read function arguments from a dedicated section.
679 find_kwargs = self.parse_section_packages__find(
680 self.sections.get('packages.find', {})
681 )
682
683 find_kwargs.update(
684 namespaces=(trimmed_value == find_directives[1]),
685 root_dir=self.root_dir,
686 fill_package_dir=self.package_dir,
687 )
688
689 return expand.find_packages(**find_kwargs)
690
691 def parse_section_packages__find(self, section_options):
692 """Parses `packages.find` configuration file section.
693
694 To be used in conjunction with _parse_packages().
695
696 :param dict section_options:
697 """
698 section_data = self._parse_section_to_dict(section_options, self._parse_list)
699
700 valid_keys = ['where', 'include', 'exclude']
701
702 find_kwargs = dict([
703 (k, v) for k, v in section_data.items() if k in valid_keys and v
704 ])
705
706 where = find_kwargs.get('where')
707 if where is not None:
708 find_kwargs['where'] = where[0] # cast list to single val
709
710 return find_kwargs
711
712 def parse_section_entry_points(self, section_options):
713 """Parses `entry_points` configuration file section.
714
715 :param dict section_options:
716 """
717 parsed = self._parse_section_to_dict(section_options, self._parse_list)
718 self['entry_points'] = parsed
719
720 def _parse_package_data(self, section_options):
721 package_data = self._parse_section_to_dict(section_options, self._parse_list)
722 return expand.canonic_package_data(package_data)
723
724 def parse_section_package_data(self, section_options):
725 """Parses `package_data` configuration file section.
726
727 :param dict section_options:
728 """
729 self['package_data'] = self._parse_package_data(section_options)
730
731 def parse_section_exclude_package_data(self, section_options):
732 """Parses `exclude_package_data` configuration file section.
733
734 :param dict section_options:
735 """
736 self['exclude_package_data'] = self._parse_package_data(section_options)
737
738 def parse_section_extras_require(self, section_options):
739 """Parses `extras_require` configuration file section.
740
741 :param dict section_options:
742 """
743 parsed = self._parse_section_to_dict_with_key(
744 section_options,
745 lambda k, v: self._parse_requirements_list(f"extras_require[{k}]", v),
746 )
747
748 self['extras_require'] = parsed
749
750 def parse_section_data_files(self, section_options):
751 """Parses `data_files` configuration file section.
752
753 :param dict section_options:
754 """
755 parsed = self._parse_section_to_dict(section_options, self._parse_list)
756 self['data_files'] = expand.canonic_data_files(parsed, self.root_dir)
757
758
759class _AmbiguousMarker(SetuptoolsDeprecationWarning):
760 _SUMMARY = "Ambiguous requirement marker."
761 _DETAILS = """
762 One of the parsed requirements in `{field}` looks like a valid environment marker:
763
764 {req!r}
765
766 Please make sure that the configuration file is correct.
767 You can use dangling lines to avoid this problem.
768 """
769 _SEE_DOCS = "userguide/declarative_config.html#opt-2"
770 # TODO: should we include due_date here? Initially introduced in 6 Aug 2022.
771 # Does this make sense with latest version of packaging?
772
773 @classmethod
774 def message(cls, **kw):
775 docs = f"https://setuptools.pypa.io/en/latest/{cls._SEE_DOCS}"
776 return cls._format(cls._SUMMARY, cls._DETAILS, see_url=docs, format_args=kw)
777
778
779class _DeprecatedConfig(SetuptoolsDeprecationWarning):
780 _SEE_DOCS = "userguide/declarative_config.html"