python (3.11.7)
       1  """Translation layer between pyproject config and setuptools distribution and
       2  metadata objects.
       3  
       4  The distribution and metadata objects are modeled after (an old version of)
       5  core metadata, therefore configs in the format specified for ``pyproject.toml``
       6  need to be processed before being applied.
       7  
       8  **PRIVATE MODULE**: API reserved for setuptools internal usage only.
       9  """
      10  import logging
      11  import os
      12  import warnings
      13  from collections.abc import Mapping
      14  from email.headerregistry import Address
      15  from functools import partial, reduce
      16  from itertools import chain
      17  from types import MappingProxyType
      18  from typing import (TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple,
      19                      Type, Union)
      20  
      21  from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
      22  
      23  if TYPE_CHECKING:
      24      from setuptools._importlib import metadata  # noqa
      25      from setuptools.dist import Distribution  # noqa
      26  
      27  EMPTY: Mapping = MappingProxyType({})  # Immutable dict-like
      28  _Path = Union[os.PathLike, str]
      29  _DictOrStr = Union[dict, str]
      30  _CorrespFn = Callable[["Distribution", Any, _Path], None]
      31  _Correspondence = Union[str, _CorrespFn]
      32  
      33  _logger = logging.getLogger(__name__)
      34  
      35  
      36  def apply(dist: "Distribution", config: dict, filename: _Path) -> "Distribution":
      37      """Apply configuration dict read with :func:`read_configuration`"""
      38  
      39      if not config:
      40          return dist  # short-circuit unrelated pyproject.toml file
      41  
      42      root_dir = os.path.dirname(filename) or "."
      43  
      44      _apply_project_table(dist, config, root_dir)
      45      _apply_tool_table(dist, config, filename)
      46  
      47      current_directory = os.getcwd()
      48      os.chdir(root_dir)
      49      try:
      50          dist._finalize_requires()
      51          dist._finalize_license_files()
      52      finally:
      53          os.chdir(current_directory)
      54  
      55      return dist
      56  
      57  
      58  def _apply_project_table(dist: "Distribution", config: dict, root_dir: _Path):
      59      project_table = config.get("project", {}).copy()
      60      if not project_table:
      61          return  # short-circuit
      62  
      63      _handle_missing_dynamic(dist, project_table)
      64      _unify_entry_points(project_table)
      65  
      66      for field, value in project_table.items():
      67          norm_key = json_compatible_key(field)
      68          corresp = PYPROJECT_CORRESPONDENCE.get(norm_key, norm_key)
      69          if callable(corresp):
      70              corresp(dist, value, root_dir)
      71          else:
      72              _set_config(dist, corresp, value)
      73  
      74  
      75  def _apply_tool_table(dist: "Distribution", config: dict, filename: _Path):
      76      tool_table = config.get("tool", {}).get("setuptools", {})
      77      if not tool_table:
      78          return  # short-circuit
      79  
      80      for field, value in tool_table.items():
      81          norm_key = json_compatible_key(field)
      82  
      83          if norm_key in TOOL_TABLE_DEPRECATIONS:
      84              suggestion = TOOL_TABLE_DEPRECATIONS[norm_key]
      85              msg = f"The parameter `{norm_key}` is deprecated, {suggestion}"
      86              warnings.warn(msg, SetuptoolsDeprecationWarning)
      87  
      88          norm_key = TOOL_TABLE_RENAMES.get(norm_key, norm_key)
      89          _set_config(dist, norm_key, value)
      90  
      91      _copy_command_options(config, dist, filename)
      92  
      93  
      94  def _handle_missing_dynamic(dist: "Distribution", project_table: dict):
      95      """Be temporarily forgiving with ``dynamic`` fields not listed in ``dynamic``"""
      96      # TODO: Set fields back to `None` once the feature stabilizes
      97      dynamic = set(project_table.get("dynamic", []))
      98      for field, getter in _PREVIOUSLY_DEFINED.items():
      99          if not (field in project_table or field in dynamic):
     100              value = getter(dist)
     101              if value:
     102                  msg = _WouldIgnoreField.message(field, value)
     103                  warnings.warn(msg, _WouldIgnoreField)
     104  
     105  
     106  def json_compatible_key(key: str) -> str:
     107      """As defined in :pep:`566#json-compatible-metadata`"""
     108      return key.lower().replace("-", "_")
     109  
     110  
     111  def _set_config(dist: "Distribution", field: str, value: Any):
     112      setter = getattr(dist.metadata, f"set_{field}", None)
     113      if setter:
     114          setter(value)
     115      elif hasattr(dist.metadata, field) or field in SETUPTOOLS_PATCHES:
     116          setattr(dist.metadata, field, value)
     117      else:
     118          setattr(dist, field, value)
     119  
     120  
     121  _CONTENT_TYPES = {
     122      ".md": "text/markdown",
     123      ".rst": "text/x-rst",
     124      ".txt": "text/plain",
     125  }
     126  
     127  
     128  def _guess_content_type(file: str) -> Optional[str]:
     129      _, ext = os.path.splitext(file.lower())
     130      if not ext:
     131          return None
     132  
     133      if ext in _CONTENT_TYPES:
     134          return _CONTENT_TYPES[ext]
     135  
     136      valid = ", ".join(f"{k} ({v})" for k, v in _CONTENT_TYPES.items())
     137      msg = f"only the following file extensions are recognized: {valid}."
     138      raise ValueError(f"Undefined content type for {file}, {msg}")
     139  
     140  
     141  def _long_description(dist: "Distribution", val: _DictOrStr, root_dir: _Path):
     142      from setuptools.config import expand
     143  
     144      if isinstance(val, str):
     145          text = expand.read_files(val, root_dir)
     146          ctype = _guess_content_type(val)
     147      else:
     148          text = val.get("text") or expand.read_files(val.get("file", []), root_dir)
     149          ctype = val["content-type"]
     150  
     151      _set_config(dist, "long_description", text)
     152      if ctype:
     153          _set_config(dist, "long_description_content_type", ctype)
     154  
     155  
     156  def _license(dist: "Distribution", val: dict, root_dir: _Path):
     157      from setuptools.config import expand
     158  
     159      if "file" in val:
     160          _set_config(dist, "license", expand.read_files([val["file"]], root_dir))
     161      else:
     162          _set_config(dist, "license", val["text"])
     163  
     164  
     165  def _people(dist: "Distribution", val: List[dict], _root_dir: _Path, kind: str):
     166      field = []
     167      email_field = []
     168      for person in val:
     169          if "name" not in person:
     170              email_field.append(person["email"])
     171          elif "email" not in person:
     172              field.append(person["name"])
     173          else:
     174              addr = Address(display_name=person["name"], addr_spec=person["email"])
     175              email_field.append(str(addr))
     176  
     177      if field:
     178          _set_config(dist, kind, ", ".join(field))
     179      if email_field:
     180          _set_config(dist, f"{kind}_email", ", ".join(email_field))
     181  
     182  
     183  def _project_urls(dist: "Distribution", val: dict, _root_dir):
     184      _set_config(dist, "project_urls", val)
     185  
     186  
     187  def _python_requires(dist: "Distribution", val: dict, _root_dir):
     188      from setuptools.extern.packaging.specifiers import SpecifierSet
     189  
     190      _set_config(dist, "python_requires", SpecifierSet(val))
     191  
     192  
     193  def _dependencies(dist: "Distribution", val: list, _root_dir):
     194      if getattr(dist, "install_requires", []):
     195          msg = "`install_requires` overwritten in `pyproject.toml` (dependencies)"
     196          warnings.warn(msg)
     197      _set_config(dist, "install_requires", val)
     198  
     199  
     200  def _optional_dependencies(dist: "Distribution", val: dict, _root_dir):
     201      existing = getattr(dist, "extras_require", {})
     202      _set_config(dist, "extras_require", {**existing, **val})
     203  
     204  
     205  def _unify_entry_points(project_table: dict):
     206      project = project_table
     207      entry_points = project.pop("entry-points", project.pop("entry_points", {}))
     208      renaming = {"scripts": "console_scripts", "gui_scripts": "gui_scripts"}
     209      for key, value in list(project.items()):  # eager to allow modifications
     210          norm_key = json_compatible_key(key)
     211          if norm_key in renaming and value:
     212              entry_points[renaming[norm_key]] = project.pop(key)
     213  
     214      if entry_points:
     215          project["entry-points"] = {
     216              name: [f"{k} = {v}" for k, v in group.items()]
     217              for name, group in entry_points.items()
     218          }
     219  
     220  
     221  def _copy_command_options(pyproject: dict, dist: "Distribution", filename: _Path):
     222      tool_table = pyproject.get("tool", {})
     223      cmdclass = tool_table.get("setuptools", {}).get("cmdclass", {})
     224      valid_options = _valid_command_options(cmdclass)
     225  
     226      cmd_opts = dist.command_options
     227      for cmd, config in pyproject.get("tool", {}).get("distutils", {}).items():
     228          cmd = json_compatible_key(cmd)
     229          valid = valid_options.get(cmd, set())
     230          cmd_opts.setdefault(cmd, {})
     231          for key, value in config.items():
     232              key = json_compatible_key(key)
     233              cmd_opts[cmd][key] = (str(filename), value)
     234              if key not in valid:
     235                  # To avoid removing options that are specified dynamically we
     236                  # just log a warn...
     237                  _logger.warning(f"Command option {cmd}.{key} is not defined")
     238  
     239  
     240  def _valid_command_options(cmdclass: Mapping = EMPTY) -> Dict[str, Set[str]]:
     241      from .._importlib import metadata
     242      from setuptools.dist import Distribution
     243  
     244      valid_options = {"global": _normalise_cmd_options(Distribution.global_options)}
     245  
     246      unloaded_entry_points = metadata.entry_points(group='distutils.commands')
     247      loaded_entry_points = (_load_ep(ep) for ep in unloaded_entry_points)
     248      entry_points = (ep for ep in loaded_entry_points if ep)
     249      for cmd, cmd_class in chain(entry_points, cmdclass.items()):
     250          opts = valid_options.get(cmd, set())
     251          opts = opts | _normalise_cmd_options(getattr(cmd_class, "user_options", []))
     252          valid_options[cmd] = opts
     253  
     254      return valid_options
     255  
     256  
     257  def _load_ep(ep: "metadata.EntryPoint") -> Optional[Tuple[str, Type]]:
     258      # Ignore all the errors
     259      try:
     260          return (ep.name, ep.load())
     261      except Exception as ex:
     262          msg = f"{ex.__class__.__name__} while trying to load entry-point {ep.name}"
     263          _logger.warning(f"{msg}: {ex}")
     264          return None
     265  
     266  
     267  def _normalise_cmd_option_key(name: str) -> str:
     268      return json_compatible_key(name).strip("_=")
     269  
     270  
     271  def _normalise_cmd_options(desc: List[Tuple[str, Optional[str], str]]) -> Set[str]:
     272      return {_normalise_cmd_option_key(fancy_option[0]) for fancy_option in desc}
     273  
     274  
     275  def _attrgetter(attr):
     276      """
     277      Similar to ``operator.attrgetter`` but returns None if ``attr`` is not found
     278      >>> from types import SimpleNamespace
     279      >>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))
     280      >>> _attrgetter("a")(obj)
     281      42
     282      >>> _attrgetter("b.c")(obj)
     283      13
     284      >>> _attrgetter("d")(obj) is None
     285      True
     286      """
     287      return partial(reduce, lambda acc, x: getattr(acc, x, None), attr.split("."))
     288  
     289  
     290  def _some_attrgetter(*items):
     291      """
     292      Return the first "truth-y" attribute or None
     293      >>> from types import SimpleNamespace
     294      >>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))
     295      >>> _some_attrgetter("d", "a", "b.c")(obj)
     296      42
     297      >>> _some_attrgetter("d", "e", "b.c", "a")(obj)
     298      13
     299      >>> _some_attrgetter("d", "e", "f")(obj) is None
     300      True
     301      """
     302      def _acessor(obj):
     303          values = (_attrgetter(i)(obj) for i in items)
     304          return next((i for i in values if i is not None), None)
     305      return _acessor
     306  
     307  
     308  PYPROJECT_CORRESPONDENCE: Dict[str, _Correspondence] = {
     309      "readme": _long_description,
     310      "license": _license,
     311      "authors": partial(_people, kind="author"),
     312      "maintainers": partial(_people, kind="maintainer"),
     313      "urls": _project_urls,
     314      "dependencies": _dependencies,
     315      "optional_dependencies": _optional_dependencies,
     316      "requires_python": _python_requires,
     317  }
     318  
     319  TOOL_TABLE_RENAMES = {"script_files": "scripts"}
     320  TOOL_TABLE_DEPRECATIONS = {
     321      "namespace_packages": "consider using implicit namespaces instead (PEP 420)."
     322  }
     323  
     324  SETUPTOOLS_PATCHES = {"long_description_content_type", "project_urls",
     325                        "provides_extras", "license_file", "license_files"}
     326  
     327  _PREVIOUSLY_DEFINED = {
     328      "name": _attrgetter("metadata.name"),
     329      "version": _attrgetter("metadata.version"),
     330      "description": _attrgetter("metadata.description"),
     331      "readme": _attrgetter("metadata.long_description"),
     332      "requires-python": _some_attrgetter("python_requires", "metadata.python_requires"),
     333      "license": _attrgetter("metadata.license"),
     334      "authors": _some_attrgetter("metadata.author", "metadata.author_email"),
     335      "maintainers": _some_attrgetter("metadata.maintainer", "metadata.maintainer_email"),
     336      "keywords": _attrgetter("metadata.keywords"),
     337      "classifiers": _attrgetter("metadata.classifiers"),
     338      "urls": _attrgetter("metadata.project_urls"),
     339      "entry-points": _attrgetter("entry_points"),
     340      "dependencies": _some_attrgetter("_orig_install_requires", "install_requires"),
     341      "optional-dependencies": _some_attrgetter("_orig_extras_require", "extras_require"),
     342  }
     343  
     344  
     345  class ESC[4;38;5;81m_WouldIgnoreField(ESC[4;38;5;149mUserWarning):
     346      """Inform users that ``pyproject.toml`` would overwrite previous metadata."""
     347  
     348      MESSAGE = """\
     349      {field!r} defined outside of `pyproject.toml` would be ignored.
     350      !!\n\n
     351      ##########################################################################
     352      # configuration would be ignored/result in error due to `pyproject.toml` #
     353      ##########################################################################
     354  
     355      The following seems to be defined outside of `pyproject.toml`:
     356  
     357      `{field} = {value!r}`
     358  
     359      According to the spec (see the link below), however, setuptools CANNOT
     360      consider this value unless {field!r} is listed as `dynamic`.
     361  
     362      https://packaging.python.org/en/latest/specifications/declaring-project-metadata/
     363  
     364      For the time being, `setuptools` will still consider the given value (as a
     365      **transitional** measure), but please note that future releases of setuptools will
     366      follow strictly the standard.
     367  
     368      To prevent this warning, you can list {field!r} under `dynamic` or alternatively
     369      remove the `[project]` table from your file and rely entirely on other means of
     370      configuration.
     371      \n\n!!
     372      """
     373  
     374      @classmethod
     375      def message(cls, field, value):
     376          from inspect import cleandoc
     377          return cleandoc(cls.MESSAGE.format(field=field, value=value))