diff --git a/build_with_distutils_stubs.py b/build_with_distutils_stubs.py new file mode 100644 index 0000000000..7fe8a129c5 --- /dev/null +++ b/build_with_distutils_stubs.py @@ -0,0 +1,73 @@ +"""Generate distutils stub files inside the source directory before packaging. +We have to do this as a custom build backend for PEP 660 editable installs. +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from setuptools._path import StrPath +from setuptools.build_meta import * # noqa: F403 # expose everything +from setuptools.build_meta import ( + _ConfigSettings, + build_editable as _build_editable, + build_sdist as _build_sdist, + build_wheel as _build_wheel, +) + +_vendored_distutils_path = Path(__file__).parent / "setuptools" / "_distutils" +_distutils_stubs_path = Path(__file__).parent / "distutils-stubs" + + +def _regenerate_distutils_stubs() -> None: + shutil.rmtree(_distutils_stubs_path, ignore_errors=True) + _distutils_stubs_path.mkdir(parents=True) + (_distutils_stubs_path / ".gitignore").write_text("*") + (_distutils_stubs_path / "ruff.toml").write_text('[lint]\nignore = ["F403"]') + (_distutils_stubs_path / "py.typed").write_text("\n") + for path in _vendored_distutils_path.rglob("*.py"): + relative_path = path.relative_to(_vendored_distutils_path) + if "tests" in relative_path.parts: + continue + stub_path = _distutils_stubs_path / relative_path.with_suffix(".pyi") + stub_path.parent.mkdir(parents=True, exist_ok=True) + module = "setuptools._distutils." + str(relative_path.with_suffix("")).replace( + os.sep, "." + ).removesuffix(".__init__") + if str(relative_path) == "__init__.py": + # Work around python/mypy#18775 + stub_path.write_text("""\ +from typing import Final + +__version__: Final[str] +""") + else: + stub_path.write_text(f"from {module} import *\n") + + +def build_wheel( # type: ignore[no-redef] + wheel_directory: StrPath, + config_settings: _ConfigSettings = None, + metadata_directory: StrPath | None = None, +) -> str: + _regenerate_distutils_stubs() + return _build_wheel(wheel_directory, config_settings, metadata_directory) + + +def build_sdist( # type: ignore[no-redef] + sdist_directory: StrPath, + config_settings: _ConfigSettings = None, +) -> str: + _regenerate_distutils_stubs() + return _build_sdist(sdist_directory, config_settings) + + +def build_editable( # type: ignore[no-redef] + wheel_directory: StrPath, + config_settings: _ConfigSettings = None, + metadata_directory: StrPath | None = None, +) -> str: + _regenerate_distutils_stubs() + return _build_editable(wheel_directory, config_settings, metadata_directory) diff --git a/mypy.ini b/mypy.ini index c1d01a42c3..823c6b47f1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5,7 +5,7 @@ strict = False # Early opt-in even when strict = False -# warn_unused_ignores = True # Disabled until we have distutils stubs for Python 3.12+ +warn_unused_ignores = True warn_redundant_casts = True enable_error_code = ignore-without-code @@ -48,14 +48,6 @@ disable_error_code = [mypy-pkg_resources.tests.*] disable_error_code = import-not-found -# - distutils doesn't exist on Python 3.12, unfortunately, this means typing -# will be missing for subclasses of distutils on Python 3.12 until either: -# - support for `SETUPTOOLS_USE_DISTUTILS=stdlib` is dropped (#3625) -# for setuptools to import `_distutils` directly -# - or non-stdlib distutils typings are exposed -[mypy-distutils.*] -ignore_missing_imports = True - # - wheel: does not intend on exposing a programmatic API https://github.com/pypa/wheel/pull/610#issuecomment-2081687671 [mypy-wheel.*] follow_untyped_imports = True diff --git a/newsfragments/4861.feature.rst b/newsfragments/4861.feature.rst new file mode 100644 index 0000000000..37541b8599 --- /dev/null +++ b/newsfragments/4861.feature.rst @@ -0,0 +1 @@ +``setuptools`` now provide its own ``distutils-stubs`` instead of relying on typeshed -- by :user:`Avasam` diff --git a/pyproject.toml b/pyproject.toml index da11bbe5c2..16b922ab07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ # jaraco/skeleton#174 # "coherent.licensed", ] -build-backend = "setuptools.build_meta" +build-backend = "build_with_distutils_stubs" backend-path = ["."] [project] @@ -205,6 +205,7 @@ include-package-data = true include = [ "setuptools*", "pkg_resources*", + "distutils-stubs*", "_distutils_hack*", ] exclude = [ diff --git a/pyrightconfig.json b/pyrightconfig.json index da3cd978ce..6123957847 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -12,6 +12,8 @@ ], // Our testing setup doesn't allow passing CLI arguments, so local devs have to set this manually. // "pythonVersion": "3.9", + // Allow using distutils-stubs on Python 3.12+ + "reportMissingModuleSource": false, // For now we don't mind if mypy's `type: ignore` comments accidentally suppresses pyright issues "enableTypeIgnoreComments": true, "typeCheckingMode": "basic", diff --git a/setuptools/__init__.py b/setuptools/__init__.py index b3e78edab6..b918b5a3d9 100644 --- a/setuptools/__init__.py +++ b/setuptools/__init__.py @@ -1,9 +1,4 @@ """Extensions to the 'distutils' for large or complex distributions""" -# mypy: disable_error_code=override -# Command.reinitialize_command has an extra **kw param that distutils doesn't have -# Can't disable on the exact line because distutils doesn't exists on Python 3.12 -# and mypy isn't aware of distutils_hack, causing distutils.core.Command to be Any, -# and a [unused-ignore] to be raised on 3.12+ from __future__ import annotations @@ -188,7 +183,7 @@ def reinitialize_command( ) -> Command | _Command: cmd = _Command.reinitialize_command(self, command, reinit_subcommands) vars(cmd).update(kw) - return cmd # pyright: ignore[reportReturnType] # pypa/distutils#307 + return cmd @abstractmethod def initialize_options(self) -> None: diff --git a/setuptools/_distutils/cmd.py b/setuptools/_distutils/cmd.py index 241621bd51..eb83dd97ff 100644 --- a/setuptools/_distutils/cmd.py +++ b/setuptools/_distutils/cmd.py @@ -1,554 +1,554 @@ -"""distutils.cmd - -Provides the Command class, the base class for the command classes -in the distutils.command package. -""" - -from __future__ import annotations - -import logging -import os -import re -import sys -from abc import abstractmethod -from collections.abc import Callable, MutableSequence -from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, overload - -from . import _modified, archive_util, dir_util, file_util, util -from ._log import log -from .errors import DistutilsOptionError - -if TYPE_CHECKING: - # type-only import because of mutual dependence between these classes - from distutils.dist import Distribution - - from typing_extensions import TypeVarTuple, Unpack - - _Ts = TypeVarTuple("_Ts") - -_StrPathT = TypeVar("_StrPathT", bound="str | os.PathLike[str]") -_BytesPathT = TypeVar("_BytesPathT", bound="bytes | os.PathLike[bytes]") -_CommandT = TypeVar("_CommandT", bound="Command") - - -class Command: - """Abstract base class for defining command classes, the "worker bees" - of the Distutils. A useful analogy for command classes is to think of - them as subroutines with local variables called "options". The options - are "declared" in 'initialize_options()' and "defined" (given their - final values, aka "finalized") in 'finalize_options()', both of which - must be defined by every command class. The distinction between the - two is necessary because option values might come from the outside - world (command line, config file, ...), and any options dependent on - other options must be computed *after* these outside influences have - been processed -- hence 'finalize_options()'. The "body" of the - subroutine, where it does all its work based on the values of its - options, is the 'run()' method, which must also be implemented by every - command class. - """ - - # 'sub_commands' formalizes the notion of a "family" of commands, - # eg. "install" as the parent with sub-commands "install_lib", - # "install_headers", etc. The parent of a family of commands - # defines 'sub_commands' as a class attribute; it's a list of - # (command_name : string, predicate : unbound_method | string | None) - # tuples, where 'predicate' is a method of the parent command that - # determines whether the corresponding command is applicable in the - # current situation. (Eg. we "install_headers" is only applicable if - # we have any C header files to install.) If 'predicate' is None, - # that command is always applicable. - # - # 'sub_commands' is usually defined at the *end* of a class, because - # predicates can be unbound methods, so they must already have been - # defined. The canonical example is the "install" command. - sub_commands: ClassVar[ # Any to work around variance issues - list[tuple[str, Callable[[Any], bool] | None]] - ] = [] - - user_options: ClassVar[ - # Specifying both because list is invariant. Avoids mypy override assignment issues - list[tuple[str, str, str]] | list[tuple[str, str | None, str]] - ] = [] - - # -- Creation/initialization methods ------------------------------- - - def __init__(self, dist: Distribution) -> None: - """Create and initialize a new Command object. Most importantly, - invokes the 'initialize_options()' method, which is the real - initializer and depends on the actual command being - instantiated. - """ - # late import because of mutual dependence between these classes - from distutils.dist import Distribution - - if not isinstance(dist, Distribution): - raise TypeError("dist must be a Distribution instance") - if self.__class__ is Command: - raise RuntimeError("Command is an abstract class") - - self.distribution = dist - self.initialize_options() - - # Per-command versions of the global flags, so that the user can - # customize Distutils' behaviour command-by-command and let some - # commands fall back on the Distribution's behaviour. None means - # "not defined, check self.distribution's copy", while 0 or 1 mean - # false and true (duh). Note that this means figuring out the real - # value of each flag is a touch complicated -- hence "self._dry_run" - # will be handled by __getattr__, below. - # XXX This needs to be fixed. - self._dry_run = None - - # verbose is largely ignored, but needs to be set for - # backwards compatibility (I think)? - self.verbose = dist.verbose - - # Some commands define a 'self.force' option to ignore file - # timestamps, but methods defined *here* assume that - # 'self.force' exists for all commands. So define it here - # just to be safe. - self.force = None - - # The 'help' flag is just used for command-line parsing, so - # none of that complicated bureaucracy is needed. - self.help = False - - # 'finalized' records whether or not 'finalize_options()' has been - # called. 'finalize_options()' itself should not pay attention to - # this flag: it is the business of 'ensure_finalized()', which - # always calls 'finalize_options()', to respect/update it. - self.finalized = False - - # XXX A more explicit way to customize dry_run would be better. - def __getattr__(self, attr): - if attr == 'dry_run': - myval = getattr(self, "_" + attr) - if myval is None: - return getattr(self.distribution, attr) - else: - return myval - else: - raise AttributeError(attr) - - def ensure_finalized(self) -> None: - if not self.finalized: - self.finalize_options() - self.finalized = True - - # Subclasses must define: - # initialize_options() - # provide default values for all options; may be customized by - # setup script, by options from config file(s), or by command-line - # options - # finalize_options() - # decide on the final values for all options; this is called - # after all possible intervention from the outside world - # (command-line, option file, etc.) has been processed - # run() - # run the command: do whatever it is we're here to do, - # controlled by the command's various option values - - @abstractmethod - def initialize_options(self) -> None: - """Set default values for all the options that this command - supports. Note that these defaults may be overridden by other - commands, by the setup script, by config files, or by the - command-line. Thus, this is not the place to code dependencies - between options; generally, 'initialize_options()' implementations - are just a bunch of "self.foo = None" assignments. - - This method must be implemented by all command classes. - """ - raise RuntimeError( - f"abstract method -- subclass {self.__class__} must override" - ) - - @abstractmethod - def finalize_options(self) -> None: - """Set final values for all the options that this command supports. - This is always called as late as possible, ie. after any option - assignments from the command-line or from other commands have been - done. Thus, this is the place to code option dependencies: if - 'foo' depends on 'bar', then it is safe to set 'foo' from 'bar' as - long as 'foo' still has the same value it was assigned in - 'initialize_options()'. - - This method must be implemented by all command classes. - """ - raise RuntimeError( - f"abstract method -- subclass {self.__class__} must override" - ) - - def dump_options(self, header=None, indent=""): - from distutils.fancy_getopt import longopt_xlate - - if header is None: - header = f"command options for '{self.get_command_name()}':" - self.announce(indent + header, level=logging.INFO) - indent = indent + " " - for option, _, _ in self.user_options: - option = option.translate(longopt_xlate) - if option[-1] == "=": - option = option[:-1] - value = getattr(self, option) - self.announce(indent + f"{option} = {value}", level=logging.INFO) - - @abstractmethod - def run(self) -> None: - """A command's raison d'etre: carry out the action it exists to - perform, controlled by the options initialized in - 'initialize_options()', customized by other commands, the setup - script, the command-line, and config files, and finalized in - 'finalize_options()'. All terminal output and filesystem - interaction should be done by 'run()'. - - This method must be implemented by all command classes. - """ - raise RuntimeError( - f"abstract method -- subclass {self.__class__} must override" - ) - - def announce(self, msg: object, level: int = logging.DEBUG) -> None: - log.log(level, msg) - - def debug_print(self, msg: object) -> None: - """Print 'msg' to stdout if the global DEBUG (taken from the - DISTUTILS_DEBUG environment variable) flag is true. - """ - from distutils.debug import DEBUG - - if DEBUG: - print(msg) - sys.stdout.flush() - - # -- Option validation methods ------------------------------------- - # (these are very handy in writing the 'finalize_options()' method) - # - # NB. the general philosophy here is to ensure that a particular option - # value meets certain type and value constraints. If not, we try to - # force it into conformance (eg. if we expect a list but have a string, - # split the string on comma and/or whitespace). If we can't force the - # option into conformance, raise DistutilsOptionError. Thus, command - # classes need do nothing more than (eg.) - # self.ensure_string_list('foo') - # and they can be guaranteed that thereafter, self.foo will be - # a list of strings. - - def _ensure_stringlike(self, option, what, default=None): - val = getattr(self, option) - if val is None: - setattr(self, option, default) - return default - elif not isinstance(val, str): - raise DistutilsOptionError(f"'{option}' must be a {what} (got `{val}`)") - return val - - def ensure_string(self, option: str, default: str | None = None) -> None: - """Ensure that 'option' is a string; if not defined, set it to - 'default'. - """ - self._ensure_stringlike(option, "string", default) - - def ensure_string_list(self, option: str) -> None: - r"""Ensure that 'option' is a list of strings. If 'option' is - currently a string, we split it either on /,\s*/ or /\s+/, so - "foo bar baz", "foo,bar,baz", and "foo, bar baz" all become - ["foo", "bar", "baz"]. - """ - val = getattr(self, option) - if val is None: - return - elif isinstance(val, str): - setattr(self, option, re.split(r',\s*|\s+', val)) - else: - if isinstance(val, list): - ok = all(isinstance(v, str) for v in val) - else: - ok = False - if not ok: - raise DistutilsOptionError( - f"'{option}' must be a list of strings (got {val!r})" - ) - - def _ensure_tested_string(self, option, tester, what, error_fmt, default=None): - val = self._ensure_stringlike(option, what, default) - if val is not None and not tester(val): - raise DistutilsOptionError( - ("error in '%s' option: " + error_fmt) % (option, val) - ) - - def ensure_filename(self, option: str) -> None: - """Ensure that 'option' is the name of an existing file.""" - self._ensure_tested_string( - option, os.path.isfile, "filename", "'%s' does not exist or is not a file" - ) - - def ensure_dirname(self, option: str) -> None: - self._ensure_tested_string( - option, - os.path.isdir, - "directory name", - "'%s' does not exist or is not a directory", - ) - - # -- Convenience methods for commands ------------------------------ - - def get_command_name(self) -> str: - if hasattr(self, 'command_name'): - return self.command_name - else: - return self.__class__.__name__ - - def set_undefined_options( - self, src_cmd: str, *option_pairs: tuple[str, str] - ) -> None: - """Set the values of any "undefined" options from corresponding - option values in some other command object. "Undefined" here means - "is None", which is the convention used to indicate that an option - has not been changed between 'initialize_options()' and - 'finalize_options()'. Usually called from 'finalize_options()' for - options that depend on some other command rather than another - option of the same command. 'src_cmd' is the other command from - which option values will be taken (a command object will be created - for it if necessary); the remaining arguments are - '(src_option,dst_option)' tuples which mean "take the value of - 'src_option' in the 'src_cmd' command object, and copy it to - 'dst_option' in the current command object". - """ - # Option_pairs: list of (src_option, dst_option) tuples - src_cmd_obj = self.distribution.get_command_obj(src_cmd) - src_cmd_obj.ensure_finalized() - for src_option, dst_option in option_pairs: - if getattr(self, dst_option) is None: - setattr(self, dst_option, getattr(src_cmd_obj, src_option)) - - # NOTE: Because distutils is private to Setuptools and not all commands are exposed here, - # not every possible command is enumerated in the signature. - def get_finalized_command(self, command: str, create: bool = True) -> Command: - """Wrapper around Distribution's 'get_command_obj()' method: find - (create if necessary and 'create' is true) the command object for - 'command', call its 'ensure_finalized()' method, and return the - finalized command object. - """ - cmd_obj = self.distribution.get_command_obj(command, create) - cmd_obj.ensure_finalized() - return cmd_obj - - # XXX rename to 'get_reinitialized_command()'? (should do the - # same in dist.py, if so) - @overload - def reinitialize_command( - self, command: str, reinit_subcommands: bool = False - ) -> Command: ... - @overload - def reinitialize_command( - self, command: _CommandT, reinit_subcommands: bool = False - ) -> _CommandT: ... - def reinitialize_command( - self, command: str | Command, reinit_subcommands=False - ) -> Command: - return self.distribution.reinitialize_command(command, reinit_subcommands) - - def run_command(self, command: str) -> None: - """Run some other command: uses the 'run_command()' method of - Distribution, which creates and finalizes the command object if - necessary and then invokes its 'run()' method. - """ - self.distribution.run_command(command) - - def get_sub_commands(self) -> list[str]: - """Determine the sub-commands that are relevant in the current - distribution (ie., that need to be run). This is based on the - 'sub_commands' class attribute: each tuple in that list may include - a method that we call to determine if the subcommand needs to be - run for the current distribution. Return a list of command names. - """ - commands = [] - for cmd_name, method in self.sub_commands: - if method is None or method(self): - commands.append(cmd_name) - return commands - - # -- External world manipulation ----------------------------------- - - def warn(self, msg: object) -> None: - log.warning("warning: %s: %s\n", self.get_command_name(), msg) - - def execute( - self, - func: Callable[[Unpack[_Ts]], object], - args: tuple[Unpack[_Ts]], - msg: object = None, - level: int = 1, - ) -> None: - util.execute(func, args, msg, dry_run=self.dry_run) - - def mkpath(self, name: str, mode: int = 0o777) -> None: - dir_util.mkpath(name, mode, dry_run=self.dry_run) - - @overload - def copy_file( - self, - infile: str | os.PathLike[str], - outfile: _StrPathT, - preserve_mode: bool = True, - preserve_times: bool = True, - link: str | None = None, - level: int = 1, - ) -> tuple[_StrPathT | str, bool]: ... - @overload - def copy_file( - self, - infile: bytes | os.PathLike[bytes], - outfile: _BytesPathT, - preserve_mode: bool = True, - preserve_times: bool = True, - link: str | None = None, - level: int = 1, - ) -> tuple[_BytesPathT | bytes, bool]: ... - def copy_file( - self, - infile: str | os.PathLike[str] | bytes | os.PathLike[bytes], - outfile: str | os.PathLike[str] | bytes | os.PathLike[bytes], - preserve_mode: bool = True, - preserve_times: bool = True, - link: str | None = None, - level: int = 1, - ) -> tuple[str | os.PathLike[str] | bytes | os.PathLike[bytes], bool]: - """Copy a file respecting verbose, dry-run and force flags. (The - former two default to whatever is in the Distribution object, and - the latter defaults to false for commands that don't define it.)""" - return file_util.copy_file( - infile, - outfile, - preserve_mode, - preserve_times, - not self.force, - link, - dry_run=self.dry_run, - ) - - def copy_tree( - self, - infile: str | os.PathLike[str], - outfile: str, - preserve_mode: bool = True, - preserve_times: bool = True, - preserve_symlinks: bool = False, - level: int = 1, - ) -> list[str]: - """Copy an entire directory tree respecting verbose, dry-run, - and force flags. - """ - return dir_util.copy_tree( - infile, - outfile, - preserve_mode, - preserve_times, - preserve_symlinks, - not self.force, - dry_run=self.dry_run, - ) - - @overload - def move_file( - self, src: str | os.PathLike[str], dst: _StrPathT, level: int = 1 - ) -> _StrPathT | str: ... - @overload - def move_file( - self, src: bytes | os.PathLike[bytes], dst: _BytesPathT, level: int = 1 - ) -> _BytesPathT | bytes: ... - def move_file( - self, - src: str | os.PathLike[str] | bytes | os.PathLike[bytes], - dst: str | os.PathLike[str] | bytes | os.PathLike[bytes], - level: int = 1, - ) -> str | os.PathLike[str] | bytes | os.PathLike[bytes]: - """Move a file respecting dry-run flag.""" - return file_util.move_file(src, dst, dry_run=self.dry_run) - - def spawn( - self, cmd: MutableSequence[str], search_path: bool = True, level: int = 1 - ) -> None: - """Spawn an external command respecting dry-run flag.""" - from distutils.spawn import spawn - - spawn(cmd, search_path, dry_run=self.dry_run) - - @overload - def make_archive( - self, - base_name: str, - format: str, - root_dir: str | os.PathLike[str] | bytes | os.PathLike[bytes] | None = None, - base_dir: str | None = None, - owner: str | None = None, - group: str | None = None, - ) -> str: ... - @overload - def make_archive( - self, - base_name: str | os.PathLike[str], - format: str, - root_dir: str | os.PathLike[str] | bytes | os.PathLike[bytes], - base_dir: str | None = None, - owner: str | None = None, - group: str | None = None, - ) -> str: ... - def make_archive( - self, - base_name: str | os.PathLike[str], - format: str, - root_dir: str | os.PathLike[str] | bytes | os.PathLike[bytes] | None = None, - base_dir: str | None = None, - owner: str | None = None, - group: str | None = None, - ) -> str: - return archive_util.make_archive( - base_name, - format, - root_dir, - base_dir, - dry_run=self.dry_run, - owner=owner, - group=group, - ) - - def make_file( - self, - infiles: str | list[str] | tuple[str, ...], - outfile: str | os.PathLike[str] | bytes | os.PathLike[bytes], - func: Callable[[Unpack[_Ts]], object], - args: tuple[Unpack[_Ts]], - exec_msg: object = None, - skip_msg: object = None, - level: int = 1, - ) -> None: - """Special case of 'execute()' for operations that process one or - more input files and generate one output file. Works just like - 'execute()', except the operation is skipped and a different - message printed if 'outfile' already exists and is newer than all - files listed in 'infiles'. If the command defined 'self.force', - and it is true, then the command is unconditionally run -- does no - timestamp checks. - """ - if skip_msg is None: - skip_msg = f"skipping {outfile} (inputs unchanged)" - - # Allow 'infiles' to be a single string - if isinstance(infiles, str): - infiles = (infiles,) - elif not isinstance(infiles, (list, tuple)): - raise TypeError("'infiles' must be a string, or a list or tuple of strings") - - if exec_msg is None: - exec_msg = "generating {} from {}".format(outfile, ', '.join(infiles)) - - # If 'outfile' must be regenerated (either because it doesn't - # exist, is out-of-date, or the 'force' flag is true) then - # perform the action that presumably regenerates it - if self.force or _modified.newer_group(infiles, outfile): - self.execute(func, args, exec_msg, level) - # Otherwise, print the "skip" message - else: - log.debug(skip_msg) +"""distutils.cmd + +Provides the Command class, the base class for the command classes +in the distutils.command package. +""" + +from __future__ import annotations + +import logging +import os +import re +import sys +from abc import abstractmethod +from collections.abc import Callable, MutableSequence +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, overload + +from . import _modified, archive_util, dir_util, file_util, util +from ._log import log +from .errors import DistutilsOptionError + +if TYPE_CHECKING: + # type-only import because of mutual dependence between these classes + from distutils.dist import Distribution + + from typing_extensions import TypeVarTuple, Unpack + + _Ts = TypeVarTuple("_Ts") + +_StrPathT = TypeVar("_StrPathT", bound="str | os.PathLike[str]") +_BytesPathT = TypeVar("_BytesPathT", bound="bytes | os.PathLike[bytes]") +_CommandT = TypeVar("_CommandT", bound="Command") + + +class Command: + """Abstract base class for defining command classes, the "worker bees" + of the Distutils. A useful analogy for command classes is to think of + them as subroutines with local variables called "options". The options + are "declared" in 'initialize_options()' and "defined" (given their + final values, aka "finalized") in 'finalize_options()', both of which + must be defined by every command class. The distinction between the + two is necessary because option values might come from the outside + world (command line, config file, ...), and any options dependent on + other options must be computed *after* these outside influences have + been processed -- hence 'finalize_options()'. The "body" of the + subroutine, where it does all its work based on the values of its + options, is the 'run()' method, which must also be implemented by every + command class. + """ + + # 'sub_commands' formalizes the notion of a "family" of commands, + # eg. "install" as the parent with sub-commands "install_lib", + # "install_headers", etc. The parent of a family of commands + # defines 'sub_commands' as a class attribute; it's a list of + # (command_name : string, predicate : unbound_method | string | None) + # tuples, where 'predicate' is a method of the parent command that + # determines whether the corresponding command is applicable in the + # current situation. (Eg. we "install_headers" is only applicable if + # we have any C header files to install.) If 'predicate' is None, + # that command is always applicable. + # + # 'sub_commands' is usually defined at the *end* of a class, because + # predicates can be unbound methods, so they must already have been + # defined. The canonical example is the "install" command. + sub_commands: ClassVar[ # Any to work around variance issues + list[tuple[str, Callable[[Any], bool] | None]] + ] = [] + + user_options: ClassVar[ + # Specifying both because list is invariant. Avoids mypy override assignment issues + list[tuple[str, str, str]] | list[tuple[str, str | None, str]] + ] = [] + + # -- Creation/initialization methods ------------------------------- + + def __init__(self, dist: Distribution) -> None: + """Create and initialize a new Command object. Most importantly, + invokes the 'initialize_options()' method, which is the real + initializer and depends on the actual command being + instantiated. + """ + # late import because of mutual dependence between these classes + from distutils.dist import Distribution + + if not isinstance(dist, Distribution): + raise TypeError("dist must be a Distribution instance") + if self.__class__ is Command: + raise RuntimeError("Command is an abstract class") + + self.distribution = dist + self.initialize_options() + + # Per-command versions of the global flags, so that the user can + # customize Distutils' behaviour command-by-command and let some + # commands fall back on the Distribution's behaviour. None means + # "not defined, check self.distribution's copy", while 0 or 1 mean + # false and true (duh). Note that this means figuring out the real + # value of each flag is a touch complicated -- hence "self._dry_run" + # will be handled by __getattr__, below. + # XXX This needs to be fixed. + self._dry_run = None + + # verbose is largely ignored, but needs to be set for + # backwards compatibility (I think)? + self.verbose = dist.verbose + + # Some commands define a 'self.force' option to ignore file + # timestamps, but methods defined *here* assume that + # 'self.force' exists for all commands. So define it here + # just to be safe. + self.force = None + + # The 'help' flag is just used for command-line parsing, so + # none of that complicated bureaucracy is needed. + self.help = False + + # 'finalized' records whether or not 'finalize_options()' has been + # called. 'finalize_options()' itself should not pay attention to + # this flag: it is the business of 'ensure_finalized()', which + # always calls 'finalize_options()', to respect/update it. + self.finalized = False + + # XXX A more explicit way to customize dry_run would be better. + def __getattr__(self, attr): + if attr == 'dry_run': + myval = getattr(self, "_" + attr) + if myval is None: + return getattr(self.distribution, attr) + else: + return myval + else: + raise AttributeError(attr) + + def ensure_finalized(self) -> None: + if not self.finalized: + self.finalize_options() + self.finalized = True + + # Subclasses must define: + # initialize_options() + # provide default values for all options; may be customized by + # setup script, by options from config file(s), or by command-line + # options + # finalize_options() + # decide on the final values for all options; this is called + # after all possible intervention from the outside world + # (command-line, option file, etc.) has been processed + # run() + # run the command: do whatever it is we're here to do, + # controlled by the command's various option values + + @abstractmethod + def initialize_options(self) -> None: + """Set default values for all the options that this command + supports. Note that these defaults may be overridden by other + commands, by the setup script, by config files, or by the + command-line. Thus, this is not the place to code dependencies + between options; generally, 'initialize_options()' implementations + are just a bunch of "self.foo = None" assignments. + + This method must be implemented by all command classes. + """ + raise RuntimeError( + f"abstract method -- subclass {self.__class__} must override" + ) + + @abstractmethod + def finalize_options(self) -> None: + """Set final values for all the options that this command supports. + This is always called as late as possible, ie. after any option + assignments from the command-line or from other commands have been + done. Thus, this is the place to code option dependencies: if + 'foo' depends on 'bar', then it is safe to set 'foo' from 'bar' as + long as 'foo' still has the same value it was assigned in + 'initialize_options()'. + + This method must be implemented by all command classes. + """ + raise RuntimeError( + f"abstract method -- subclass {self.__class__} must override" + ) + + def dump_options(self, header=None, indent=""): + from distutils.fancy_getopt import longopt_xlate + + if header is None: + header = f"command options for '{self.get_command_name()}':" + self.announce(indent + header, level=logging.INFO) + indent = indent + " " + for option, _, _ in self.user_options: + option = option.translate(longopt_xlate) + if option[-1] == "=": + option = option[:-1] + value = getattr(self, option) + self.announce(indent + f"{option} = {value}", level=logging.INFO) + + @abstractmethod + def run(self) -> None: + """A command's raison d'etre: carry out the action it exists to + perform, controlled by the options initialized in + 'initialize_options()', customized by other commands, the setup + script, the command-line, and config files, and finalized in + 'finalize_options()'. All terminal output and filesystem + interaction should be done by 'run()'. + + This method must be implemented by all command classes. + """ + raise RuntimeError( + f"abstract method -- subclass {self.__class__} must override" + ) + + def announce(self, msg: object, level: int = logging.DEBUG) -> None: + log.log(level, msg) + + def debug_print(self, msg: object) -> None: + """Print 'msg' to stdout if the global DEBUG (taken from the + DISTUTILS_DEBUG environment variable) flag is true. + """ + from distutils.debug import DEBUG + + if DEBUG: + print(msg) + sys.stdout.flush() + + # -- Option validation methods ------------------------------------- + # (these are very handy in writing the 'finalize_options()' method) + # + # NB. the general philosophy here is to ensure that a particular option + # value meets certain type and value constraints. If not, we try to + # force it into conformance (eg. if we expect a list but have a string, + # split the string on comma and/or whitespace). If we can't force the + # option into conformance, raise DistutilsOptionError. Thus, command + # classes need do nothing more than (eg.) + # self.ensure_string_list('foo') + # and they can be guaranteed that thereafter, self.foo will be + # a list of strings. + + def _ensure_stringlike(self, option, what, default=None): + val = getattr(self, option) + if val is None: + setattr(self, option, default) + return default + elif not isinstance(val, str): + raise DistutilsOptionError(f"'{option}' must be a {what} (got `{val}`)") + return val + + def ensure_string(self, option: str, default: str | None = None) -> None: + """Ensure that 'option' is a string; if not defined, set it to + 'default'. + """ + self._ensure_stringlike(option, "string", default) + + def ensure_string_list(self, option: str) -> None: + r"""Ensure that 'option' is a list of strings. If 'option' is + currently a string, we split it either on /,\s*/ or /\s+/, so + "foo bar baz", "foo,bar,baz", and "foo, bar baz" all become + ["foo", "bar", "baz"]. + """ + val = getattr(self, option) + if val is None: + return + elif isinstance(val, str): + setattr(self, option, re.split(r',\s*|\s+', val)) + else: + if isinstance(val, list): + ok = all(isinstance(v, str) for v in val) + else: + ok = False + if not ok: + raise DistutilsOptionError( + f"'{option}' must be a list of strings (got {val!r})" + ) + + def _ensure_tested_string(self, option, tester, what, error_fmt, default=None): + val = self._ensure_stringlike(option, what, default) + if val is not None and not tester(val): + raise DistutilsOptionError( + ("error in '%s' option: " + error_fmt) % (option, val) + ) + + def ensure_filename(self, option: str) -> None: + """Ensure that 'option' is the name of an existing file.""" + self._ensure_tested_string( + option, os.path.isfile, "filename", "'%s' does not exist or is not a file" + ) + + def ensure_dirname(self, option: str) -> None: + self._ensure_tested_string( + option, + os.path.isdir, + "directory name", + "'%s' does not exist or is not a directory", + ) + + # -- Convenience methods for commands ------------------------------ + + def get_command_name(self) -> str: + if hasattr(self, 'command_name'): + return self.command_name + else: + return self.__class__.__name__ + + def set_undefined_options( + self, src_cmd: str, *option_pairs: tuple[str, str] + ) -> None: + """Set the values of any "undefined" options from corresponding + option values in some other command object. "Undefined" here means + "is None", which is the convention used to indicate that an option + has not been changed between 'initialize_options()' and + 'finalize_options()'. Usually called from 'finalize_options()' for + options that depend on some other command rather than another + option of the same command. 'src_cmd' is the other command from + which option values will be taken (a command object will be created + for it if necessary); the remaining arguments are + '(src_option,dst_option)' tuples which mean "take the value of + 'src_option' in the 'src_cmd' command object, and copy it to + 'dst_option' in the current command object". + """ + # Option_pairs: list of (src_option, dst_option) tuples + src_cmd_obj = self.distribution.get_command_obj(src_cmd) + src_cmd_obj.ensure_finalized() + for src_option, dst_option in option_pairs: + if getattr(self, dst_option) is None: + setattr(self, dst_option, getattr(src_cmd_obj, src_option)) + + # NOTE: Because distutils is private to Setuptools and not all commands are exposed here, + # not every possible command is enumerated in the signature. + def get_finalized_command(self, command: str, create: bool = True) -> Command: + """Wrapper around Distribution's 'get_command_obj()' method: find + (create if necessary and 'create' is true) the command object for + 'command', call its 'ensure_finalized()' method, and return the + finalized command object. + """ + cmd_obj = self.distribution.get_command_obj(command, create) + cmd_obj.ensure_finalized() + return cmd_obj + + # XXX rename to 'get_reinitialized_command()'? (should do the + # same in dist.py, if so) + @overload + def reinitialize_command( + self, command: str, reinit_subcommands: bool = False + ) -> Command: ... + @overload + def reinitialize_command( + self, command: _CommandT, reinit_subcommands: bool = False + ) -> _CommandT: ... + def reinitialize_command( + self, command: str | Command, reinit_subcommands=False + ) -> Command: + return self.distribution.reinitialize_command(command, reinit_subcommands) + + def run_command(self, command: str) -> None: + """Run some other command: uses the 'run_command()' method of + Distribution, which creates and finalizes the command object if + necessary and then invokes its 'run()' method. + """ + self.distribution.run_command(command) + + def get_sub_commands(self) -> list[str]: + """Determine the sub-commands that are relevant in the current + distribution (ie., that need to be run). This is based on the + 'sub_commands' class attribute: each tuple in that list may include + a method that we call to determine if the subcommand needs to be + run for the current distribution. Return a list of command names. + """ + commands = [] + for cmd_name, method in self.sub_commands: + if method is None or method(self): + commands.append(cmd_name) + return commands + + # -- External world manipulation ----------------------------------- + + def warn(self, msg: object) -> None: + log.warning("warning: %s: %s\n", self.get_command_name(), msg) + + def execute( + self, + func: Callable[[Unpack[_Ts]], object], + args: tuple[Unpack[_Ts]], + msg: object = None, + level: int = 1, + ) -> None: + util.execute(func, args, msg, dry_run=self.dry_run) + + def mkpath(self, name: str, mode: int = 0o777) -> None: + dir_util.mkpath(name, mode, dry_run=self.dry_run) + + @overload + def copy_file( + self, + infile: str | os.PathLike[str], + outfile: _StrPathT, + preserve_mode: bool = True, + preserve_times: bool = True, + link: str | None = None, + level: int = 1, + ) -> tuple[_StrPathT | str, bool]: ... + @overload + def copy_file( + self, + infile: bytes | os.PathLike[bytes], + outfile: _BytesPathT, + preserve_mode: bool = True, + preserve_times: bool = True, + link: str | None = None, + level: int = 1, + ) -> tuple[_BytesPathT | bytes, bool]: ... + def copy_file( + self, + infile: str | os.PathLike[str] | bytes | os.PathLike[bytes], + outfile: str | os.PathLike[str] | bytes | os.PathLike[bytes], + preserve_mode: bool = True, + preserve_times: bool = True, + link: str | None = None, + level: int = 1, + ) -> tuple[str | os.PathLike[str] | bytes | os.PathLike[bytes], bool]: + """Copy a file respecting verbose, dry-run and force flags. (The + former two default to whatever is in the Distribution object, and + the latter defaults to false for commands that don't define it.)""" + return file_util.copy_file( + infile, + outfile, + preserve_mode, + preserve_times, + not self.force, + link, + dry_run=self.dry_run, + ) + + def copy_tree( + self, + infile: str | os.PathLike[str], + outfile: str, + preserve_mode: bool = True, + preserve_times: bool = True, + preserve_symlinks: bool = False, + level: int = 1, + ) -> list[str]: + """Copy an entire directory tree respecting verbose, dry-run, + and force flags. + """ + return dir_util.copy_tree( + infile, + outfile, + preserve_mode, + preserve_times, + preserve_symlinks, + not self.force, + dry_run=self.dry_run, + ) + + @overload + def move_file( + self, src: str | os.PathLike[str], dst: _StrPathT, level: int = 1 + ) -> _StrPathT | str: ... + @overload + def move_file( + self, src: bytes | os.PathLike[bytes], dst: _BytesPathT, level: int = 1 + ) -> _BytesPathT | bytes: ... + def move_file( + self, + src: str | os.PathLike[str] | bytes | os.PathLike[bytes], + dst: str | os.PathLike[str] | bytes | os.PathLike[bytes], + level: int = 1, + ) -> str | os.PathLike[str] | bytes | os.PathLike[bytes]: + """Move a file respecting dry-run flag.""" + return file_util.move_file(src, dst, dry_run=self.dry_run) + + def spawn( + self, cmd: MutableSequence[str], search_path: bool = True, level: int = 1 + ) -> None: + """Spawn an external command respecting dry-run flag.""" + from distutils.spawn import spawn + + spawn(cmd, search_path, dry_run=self.dry_run) + + @overload + def make_archive( + self, + base_name: str, + format: str, + root_dir: str | os.PathLike[str] | bytes | os.PathLike[bytes] | None = None, + base_dir: str | None = None, + owner: str | None = None, + group: str | None = None, + ) -> str: ... + @overload + def make_archive( + self, + base_name: str | os.PathLike[str], + format: str, + root_dir: str | os.PathLike[str] | bytes | os.PathLike[bytes], + base_dir: str | None = None, + owner: str | None = None, + group: str | None = None, + ) -> str: ... + def make_archive( + self, + base_name: str | os.PathLike[str], + format: str, + root_dir: str | os.PathLike[str] | bytes | os.PathLike[bytes] | None = None, + base_dir: str | None = None, + owner: str | None = None, + group: str | None = None, + ) -> str: + return archive_util.make_archive( + base_name, + format, + root_dir, + base_dir, + dry_run=self.dry_run, + owner=owner, + group=group, + ) + + def make_file( + self, + infiles: str | list[str] | tuple[str, ...], + outfile: str | os.PathLike[str] | bytes | os.PathLike[bytes], + func: Callable[[Unpack[_Ts]], object], + args: tuple[Unpack[_Ts]], + exec_msg: object = None, + skip_msg: object = None, + level: int = 1, + ) -> None: + """Special case of 'execute()' for operations that process one or + more input files and generate one output file. Works just like + 'execute()', except the operation is skipped and a different + message printed if 'outfile' already exists and is newer than all + files listed in 'infiles'. If the command defined 'self.force', + and it is true, then the command is unconditionally run -- does no + timestamp checks. + """ + if skip_msg is None: + skip_msg = f"skipping {outfile} (inputs unchanged)" + + # Allow 'infiles' to be a single string + if isinstance(infiles, str): + infiles = (infiles,) + elif not isinstance(infiles, (list, tuple)): + raise TypeError("'infiles' must be a string, or a list or tuple of strings") + + if exec_msg is None: + exec_msg = "generating {} from {}".format(outfile, ', '.join(infiles)) + + # If 'outfile' must be regenerated (either because it doesn't + # exist, is out-of-date, or the 'force' flag is true) then + # perform the action that presumably regenerates it + if self.force or _modified.newer_group(infiles, outfile): + self.execute(func, args, exec_msg, level) + # Otherwise, print the "skip" message + else: + log.debug(skip_msg) diff --git a/setuptools/_distutils/command/build.py b/setuptools/_distutils/command/build.py index 6a8303a954..f2fd7f682d 100644 --- a/setuptools/_distutils/command/build.py +++ b/setuptools/_distutils/command/build.py @@ -136,16 +136,16 @@ def run(self) -> None: # -- Predicates for the sub-command list --------------------------- - def has_pure_modules(self): + def has_pure_modules(self) -> bool: return self.distribution.has_pure_modules() - def has_c_libraries(self): + def has_c_libraries(self) -> bool: return self.distribution.has_c_libraries() - def has_ext_modules(self): + def has_ext_modules(self) -> bool: return self.distribution.has_ext_modules() - def has_scripts(self): + def has_scripts(self) -> bool: return self.distribution.has_scripts() sub_commands = [ diff --git a/setuptools/_distutils/command/install.py b/setuptools/_distutils/command/install.py index dc17e56a80..8421d54e00 100644 --- a/setuptools/_distutils/command/install.py +++ b/setuptools/_distutils/command/install.py @@ -264,12 +264,12 @@ def initialize_options(self) -> None: # supplied by the user, they are filled in using the installation # scheme implied by prefix/exec-prefix/home and the contents of # that installation scheme. - self.install_purelib = None # for pure module distributions - self.install_platlib = None # non-pure (dists w/ extensions) - self.install_headers = None # for C/C++ headers + self.install_purelib: str | None = None # for pure module distributions + self.install_platlib: str | None = None # non-pure (dists w/ extensions) + self.install_headers: str | None = None # for C/C++ headers self.install_lib: str | None = None # set to either purelib or platlib - self.install_scripts = None - self.install_data = None + self.install_scripts: str | None = None + self.install_data: str | None = None self.install_userbase = USER_BASE self.install_usersite = USER_SITE @@ -772,24 +772,24 @@ def get_inputs(self): # -- Predicates for sub-command list ------------------------------- - def has_lib(self): + def has_lib(self) -> bool: """Returns true if the current distribution has any Python modules to install.""" return ( self.distribution.has_pure_modules() or self.distribution.has_ext_modules() ) - def has_headers(self): + def has_headers(self) -> bool: """Returns true if the current distribution has any headers to install.""" return self.distribution.has_headers() - def has_scripts(self): + def has_scripts(self) -> bool: """Returns true if the current distribution has any scripts to. install.""" return self.distribution.has_scripts() - def has_data(self): + def has_data(self) -> bool: """Returns true if the current distribution has any data to. install.""" return self.distribution.has_data_files() diff --git a/setuptools/_distutils/command/install_egg_info.py b/setuptools/_distutils/command/install_egg_info.py index 230e94ab46..d3781b75e4 100644 --- a/setuptools/_distutils/command/install_egg_info.py +++ b/setuptools/_distutils/command/install_egg_info.py @@ -60,9 +60,9 @@ def get_outputs(self): return self.outputs -# The following routines are taken from setuptools' pkg_resources module and -# can be replaced by importing them from pkg_resources once it is included -# in the stdlib. +# The following routines were originally copied from setuptools' pkg_resources +# module and intended to be replaced by stdlib versions. They're now just legacy +# cruft. def safe_name(name): diff --git a/setuptools/_distutils/compilers/C/base.py b/setuptools/_distutils/compilers/C/base.py index 5efd2a39d6..38e05cdf4d 100644 --- a/setuptools/_distutils/compilers/C/base.py +++ b/setuptools/_distutils/compilers/C/base.py @@ -1,1394 +1,1394 @@ -"""distutils.ccompiler - -Contains Compiler, an abstract base class that defines the interface -for the Distutils compiler abstraction model.""" - -from __future__ import annotations - -import os -import pathlib -import re -import sys -import warnings -from collections.abc import Callable, Iterable, MutableSequence, Sequence -from typing import ( - TYPE_CHECKING, - ClassVar, - Literal, - TypeVar, - Union, - overload, -) - -from more_itertools import always_iterable - -from ..._log import log -from ..._modified import newer_group -from ...dir_util import mkpath -from ...errors import ( - DistutilsModuleError, - DistutilsPlatformError, -) -from ...file_util import move_file -from ...spawn import spawn -from ...util import execute, is_mingw, split_quoted -from .errors import ( - CompileError, - LinkError, - UnknownFileType, -) - -if TYPE_CHECKING: - from typing_extensions import TypeAlias, TypeVarTuple, Unpack - - _Ts = TypeVarTuple("_Ts") - -_Macro: TypeAlias = Union[tuple[str], tuple[str, Union[str, None]]] -_StrPathT = TypeVar("_StrPathT", bound="str | os.PathLike[str]") -_BytesPathT = TypeVar("_BytesPathT", bound="bytes | os.PathLike[bytes]") - - -class Compiler: - """Abstract base class to define the interface that must be implemented - by real compiler classes. Also has some utility methods used by - several compiler classes. - - The basic idea behind a compiler abstraction class is that each - instance can be used for all the compile/link steps in building a - single project. Thus, attributes common to all of those compile and - link steps -- include directories, macros to define, libraries to link - against, etc. -- are attributes of the compiler instance. To allow for - variability in how individual files are treated, most of those - attributes may be varied on a per-compilation or per-link basis. - """ - - # 'compiler_type' is a class attribute that identifies this class. It - # keeps code that wants to know what kind of compiler it's dealing with - # from having to import all possible compiler classes just to do an - # 'isinstance'. In concrete CCompiler subclasses, 'compiler_type' - # should really, really be one of the keys of the 'compiler_class' - # dictionary (see below -- used by the 'new_compiler()' factory - # function) -- authors of new compiler interface classes are - # responsible for updating 'compiler_class'! - compiler_type: ClassVar[str] = None # type: ignore[assignment] - - # XXX things not handled by this compiler abstraction model: - # * client can't provide additional options for a compiler, - # e.g. warning, optimization, debugging flags. Perhaps this - # should be the domain of concrete compiler abstraction classes - # (UnixCCompiler, MSVCCompiler, etc.) -- or perhaps the base - # class should have methods for the common ones. - # * can't completely override the include or library searchg - # path, ie. no "cc -I -Idir1 -Idir2" or "cc -L -Ldir1 -Ldir2". - # I'm not sure how widely supported this is even by Unix - # compilers, much less on other platforms. And I'm even less - # sure how useful it is; maybe for cross-compiling, but - # support for that is a ways off. (And anyways, cross - # compilers probably have a dedicated binary with the - # right paths compiled in. I hope.) - # * can't do really freaky things with the library list/library - # dirs, e.g. "-Ldir1 -lfoo -Ldir2 -lfoo" to link against - # different versions of libfoo.a in different locations. I - # think this is useless without the ability to null out the - # library search path anyways. - - executables: ClassVar[dict] - - # Subclasses that rely on the standard filename generation methods - # implemented below should override these; see the comment near - # those methods ('object_filenames()' et. al.) for details: - src_extensions: ClassVar[list[str] | None] = None - obj_extension: ClassVar[str | None] = None - static_lib_extension: ClassVar[str | None] = None - shared_lib_extension: ClassVar[str | None] = None - static_lib_format: ClassVar[str | None] = None # format string - shared_lib_format: ClassVar[str | None] = None # prob. same as static_lib_format - exe_extension: ClassVar[str | None] = None - - # Default language settings. language_map is used to detect a source - # file or Extension target language, checking source filenames. - # language_order is used to detect the language precedence, when deciding - # what language to use when mixing source types. For example, if some - # extension has two files with ".c" extension, and one with ".cpp", it - # is still linked as c++. - language_map: ClassVar[dict[str, str]] = { - ".c": "c", - ".cc": "c++", - ".cpp": "c++", - ".cxx": "c++", - ".m": "objc", - } - language_order: ClassVar[list[str]] = ["c++", "objc", "c"] - - include_dirs: list[str] = [] - """ - include dirs specific to this compiler class - """ - - library_dirs: list[str] = [] - """ - library dirs specific to this compiler class - """ - - def __init__( - self, verbose: bool = False, dry_run: bool = False, force: bool = False - ) -> None: - self.dry_run = dry_run - self.force = force - self.verbose = verbose - - # 'output_dir': a common output directory for object, library, - # shared object, and shared library files - self.output_dir: str | None = None - - # 'macros': a list of macro definitions (or undefinitions). A - # macro definition is a 2-tuple (name, value), where the value is - # either a string or None (no explicit value). A macro - # undefinition is a 1-tuple (name,). - self.macros: list[_Macro] = [] - - # 'include_dirs': a list of directories to search for include files - self.include_dirs = [] - - # 'libraries': a list of libraries to include in any link - # (library names, not filenames: eg. "foo" not "libfoo.a") - self.libraries: list[str] = [] - - # 'library_dirs': a list of directories to search for libraries - self.library_dirs = [] - - # 'runtime_library_dirs': a list of directories to search for - # shared libraries/objects at runtime - self.runtime_library_dirs: list[str] = [] - - # 'objects': a list of object files (or similar, such as explicitly - # named library files) to include on any link - self.objects: list[str] = [] - - for key in self.executables.keys(): - self.set_executable(key, self.executables[key]) - - def set_executables(self, **kwargs: str) -> None: - """Define the executables (and options for them) that will be run - to perform the various stages of compilation. The exact set of - executables that may be specified here depends on the compiler - class (via the 'executables' class attribute), but most will have: - compiler the C/C++ compiler - linker_so linker used to create shared objects and libraries - linker_exe linker used to create binary executables - archiver static library creator - - On platforms with a command-line (Unix, DOS/Windows), each of these - is a string that will be split into executable name and (optional) - list of arguments. (Splitting the string is done similarly to how - Unix shells operate: words are delimited by spaces, but quotes and - backslashes can override this. See - 'distutils.util.split_quoted()'.) - """ - - # Note that some CCompiler implementation classes will define class - # attributes 'cpp', 'cc', etc. with hard-coded executable names; - # this is appropriate when a compiler class is for exactly one - # compiler/OS combination (eg. MSVCCompiler). Other compiler - # classes (UnixCCompiler, in particular) are driven by information - # discovered at run-time, since there are many different ways to do - # basically the same things with Unix C compilers. - - for key in kwargs: - if key not in self.executables: - raise ValueError( - f"unknown executable '{key}' for class {self.__class__.__name__}" - ) - self.set_executable(key, kwargs[key]) - - def set_executable(self, key, value): - if isinstance(value, str): - setattr(self, key, split_quoted(value)) - else: - setattr(self, key, value) - - def _find_macro(self, name): - i = 0 - for defn in self.macros: - if defn[0] == name: - return i - i += 1 - return None - - def _check_macro_definitions(self, definitions): - """Ensure that every element of 'definitions' is valid.""" - for defn in definitions: - self._check_macro_definition(*defn) - - def _check_macro_definition(self, defn): - """ - Raise a TypeError if defn is not valid. - - A valid definition is either a (name, value) 2-tuple or a (name,) tuple. - """ - if not isinstance(defn, tuple) or not self._is_valid_macro(*defn): - raise TypeError( - f"invalid macro definition '{defn}': " - "must be tuple (string,), (string, string), or (string, None)" - ) - - @staticmethod - def _is_valid_macro(name, value=None): - """ - A valid macro is a ``name : str`` and a ``value : str | None``. - - >>> Compiler._is_valid_macro('foo', None) - True - """ - return isinstance(name, str) and isinstance(value, (str, type(None))) - - # -- Bookkeeping methods ------------------------------------------- - - def define_macro(self, name: str, value: str | None = None) -> None: - """Define a preprocessor macro for all compilations driven by this - compiler object. The optional parameter 'value' should be a - string; if it is not supplied, then the macro will be defined - without an explicit value and the exact outcome depends on the - compiler used (XXX true? does ANSI say anything about this?) - """ - # Delete from the list of macro definitions/undefinitions if - # already there (so that this one will take precedence). - i = self._find_macro(name) - if i is not None: - del self.macros[i] - - self.macros.append((name, value)) - - def undefine_macro(self, name: str) -> None: - """Undefine a preprocessor macro for all compilations driven by - this compiler object. If the same macro is defined by - 'define_macro()' and undefined by 'undefine_macro()' the last call - takes precedence (including multiple redefinitions or - undefinitions). If the macro is redefined/undefined on a - per-compilation basis (ie. in the call to 'compile()'), then that - takes precedence. - """ - # Delete from the list of macro definitions/undefinitions if - # already there (so that this one will take precedence). - i = self._find_macro(name) - if i is not None: - del self.macros[i] - - undefn = (name,) - self.macros.append(undefn) - - def add_include_dir(self, dir: str) -> None: - """Add 'dir' to the list of directories that will be searched for - header files. The compiler is instructed to search directories in - the order in which they are supplied by successive calls to - 'add_include_dir()'. - """ - self.include_dirs.append(dir) - - def set_include_dirs(self, dirs: list[str]) -> None: - """Set the list of directories that will be searched to 'dirs' (a - list of strings). Overrides any preceding calls to - 'add_include_dir()'; subsequence calls to 'add_include_dir()' add - to the list passed to 'set_include_dirs()'. This does not affect - any list of standard include directories that the compiler may - search by default. - """ - self.include_dirs = dirs[:] - - def add_library(self, libname: str) -> None: - """Add 'libname' to the list of libraries that will be included in - all links driven by this compiler object. Note that 'libname' - should *not* be the name of a file containing a library, but the - name of the library itself: the actual filename will be inferred by - the linker, the compiler, or the compiler class (depending on the - platform). - - The linker will be instructed to link against libraries in the - order they were supplied to 'add_library()' and/or - 'set_libraries()'. It is perfectly valid to duplicate library - names; the linker will be instructed to link against libraries as - many times as they are mentioned. - """ - self.libraries.append(libname) - - def set_libraries(self, libnames: list[str]) -> None: - """Set the list of libraries to be included in all links driven by - this compiler object to 'libnames' (a list of strings). This does - not affect any standard system libraries that the linker may - include by default. - """ - self.libraries = libnames[:] - - def add_library_dir(self, dir: str) -> None: - """Add 'dir' to the list of directories that will be searched for - libraries specified to 'add_library()' and 'set_libraries()'. The - linker will be instructed to search for libraries in the order they - are supplied to 'add_library_dir()' and/or 'set_library_dirs()'. - """ - self.library_dirs.append(dir) - - def set_library_dirs(self, dirs: list[str]) -> None: - """Set the list of library search directories to 'dirs' (a list of - strings). This does not affect any standard library search path - that the linker may search by default. - """ - self.library_dirs = dirs[:] - - def add_runtime_library_dir(self, dir: str) -> None: - """Add 'dir' to the list of directories that will be searched for - shared libraries at runtime. - """ - self.runtime_library_dirs.append(dir) - - def set_runtime_library_dirs(self, dirs: list[str]) -> None: - """Set the list of directories to search for shared libraries at - runtime to 'dirs' (a list of strings). This does not affect any - standard search path that the runtime linker may search by - default. - """ - self.runtime_library_dirs = dirs[:] - - def add_link_object(self, object: str) -> None: - """Add 'object' to the list of object files (or analogues, such as - explicitly named library files or the output of "resource - compilers") to be included in every link driven by this compiler - object. - """ - self.objects.append(object) - - def set_link_objects(self, objects: list[str]) -> None: - """Set the list of object files (or analogues) to be included in - every link to 'objects'. This does not affect any standard object - files that the linker may include by default (such as system - libraries). - """ - self.objects = objects[:] - - # -- Private utility methods -------------------------------------- - # (here for the convenience of subclasses) - - # Helper method to prep compiler in subclass compile() methods - - def _setup_compile( - self, - outdir: str | None, - macros: list[_Macro] | None, - incdirs: list[str] | tuple[str, ...] | None, - sources, - depends, - extra, - ): - """Process arguments and decide which source files to compile.""" - outdir, macros, incdirs = self._fix_compile_args(outdir, macros, incdirs) - - if extra is None: - extra = [] - - # Get the list of expected output (object) files - objects = self.object_filenames(sources, strip_dir=False, output_dir=outdir) - assert len(objects) == len(sources) - - pp_opts = gen_preprocess_options(macros, incdirs) - - build = {} - for i in range(len(sources)): - src = sources[i] - obj = objects[i] - ext = os.path.splitext(src)[1] - self.mkpath(os.path.dirname(obj)) - build[obj] = (src, ext) - - return macros, objects, extra, pp_opts, build - - def _get_cc_args(self, pp_opts, debug, before): - # works for unixccompiler, cygwinccompiler - cc_args = pp_opts + ['-c'] - if debug: - cc_args[:0] = ['-g'] - if before: - cc_args[:0] = before - return cc_args - - def _fix_compile_args( - self, - output_dir: str | None, - macros: list[_Macro] | None, - include_dirs: list[str] | tuple[str, ...] | None, - ) -> tuple[str, list[_Macro], list[str]]: - """Typecheck and fix-up some of the arguments to the 'compile()' - method, and return fixed-up values. Specifically: if 'output_dir' - is None, replaces it with 'self.output_dir'; ensures that 'macros' - is a list, and augments it with 'self.macros'; ensures that - 'include_dirs' is a list, and augments it with 'self.include_dirs'. - Guarantees that the returned values are of the correct type, - i.e. for 'output_dir' either string or None, and for 'macros' and - 'include_dirs' either list or None. - """ - if output_dir is None: - output_dir = self.output_dir - elif not isinstance(output_dir, str): - raise TypeError("'output_dir' must be a string or None") - - if macros is None: - macros = list(self.macros) - elif isinstance(macros, list): - macros = macros + (self.macros or []) - else: - raise TypeError("'macros' (if supplied) must be a list of tuples") - - if include_dirs is None: - include_dirs = list(self.include_dirs) - elif isinstance(include_dirs, (list, tuple)): - include_dirs = list(include_dirs) + (self.include_dirs or []) - else: - raise TypeError("'include_dirs' (if supplied) must be a list of strings") - - # add include dirs for class - include_dirs += self.__class__.include_dirs - - return output_dir, macros, include_dirs - - def _prep_compile(self, sources, output_dir, depends=None): - """Decide which source files must be recompiled. - - Determine the list of object files corresponding to 'sources', - and figure out which ones really need to be recompiled. - Return a list of all object files and a dictionary telling - which source files can be skipped. - """ - # Get the list of expected output (object) files - objects = self.object_filenames(sources, output_dir=output_dir) - assert len(objects) == len(sources) - - # Return an empty dict for the "which source files can be skipped" - # return value to preserve API compatibility. - return objects, {} - - def _fix_object_args( - self, objects: list[str] | tuple[str, ...], output_dir: str | None - ) -> tuple[list[str], str]: - """Typecheck and fix up some arguments supplied to various methods. - Specifically: ensure that 'objects' is a list; if output_dir is - None, replace with self.output_dir. Return fixed versions of - 'objects' and 'output_dir'. - """ - if not isinstance(objects, (list, tuple)): - raise TypeError("'objects' must be a list or tuple of strings") - objects = list(objects) - - if output_dir is None: - output_dir = self.output_dir - elif not isinstance(output_dir, str): - raise TypeError("'output_dir' must be a string or None") - - return (objects, output_dir) - - def _fix_lib_args( - self, - libraries: list[str] | tuple[str, ...] | None, - library_dirs: list[str] | tuple[str, ...] | None, - runtime_library_dirs: list[str] | tuple[str, ...] | None, - ) -> tuple[list[str], list[str], list[str]]: - """Typecheck and fix up some of the arguments supplied to the - 'link_*' methods. Specifically: ensure that all arguments are - lists, and augment them with their permanent versions - (eg. 'self.libraries' augments 'libraries'). Return a tuple with - fixed versions of all arguments. - """ - if libraries is None: - libraries = list(self.libraries) - elif isinstance(libraries, (list, tuple)): - libraries = list(libraries) + (self.libraries or []) - else: - raise TypeError("'libraries' (if supplied) must be a list of strings") - - if library_dirs is None: - library_dirs = list(self.library_dirs) - elif isinstance(library_dirs, (list, tuple)): - library_dirs = list(library_dirs) + (self.library_dirs or []) - else: - raise TypeError("'library_dirs' (if supplied) must be a list of strings") - - # add library dirs for class - library_dirs += self.__class__.library_dirs - - if runtime_library_dirs is None: - runtime_library_dirs = list(self.runtime_library_dirs) - elif isinstance(runtime_library_dirs, (list, tuple)): - runtime_library_dirs = list(runtime_library_dirs) + ( - self.runtime_library_dirs or [] - ) - else: - raise TypeError( - "'runtime_library_dirs' (if supplied) must be a list of strings" - ) - - return (libraries, library_dirs, runtime_library_dirs) - - def _need_link(self, objects, output_file): - """Return true if we need to relink the files listed in 'objects' - to recreate 'output_file'. - """ - if self.force: - return True - else: - if self.dry_run: - newer = newer_group(objects, output_file, missing='newer') - else: - newer = newer_group(objects, output_file) - return newer - - def detect_language(self, sources: str | list[str]) -> str | None: - """Detect the language of a given file, or list of files. Uses - language_map, and language_order to do the job. - """ - if not isinstance(sources, list): - sources = [sources] - lang = None - index = len(self.language_order) - for source in sources: - base, ext = os.path.splitext(source) - extlang = self.language_map.get(ext) - try: - extindex = self.language_order.index(extlang) - if extindex < index: - lang = extlang - index = extindex - except ValueError: - pass - return lang - - # -- Worker methods ------------------------------------------------ - # (must be implemented by subclasses) - - def preprocess( - self, - source: str | os.PathLike[str], - output_file: str | os.PathLike[str] | None = None, - macros: list[_Macro] | None = None, - include_dirs: list[str] | tuple[str, ...] | None = None, - extra_preargs: list[str] | None = None, - extra_postargs: Iterable[str] | None = None, - ): - """Preprocess a single C/C++ source file, named in 'source'. - Output will be written to file named 'output_file', or stdout if - 'output_file' not supplied. 'macros' is a list of macro - definitions as for 'compile()', which will augment the macros set - with 'define_macro()' and 'undefine_macro()'. 'include_dirs' is a - list of directory names that will be added to the default list. - - Raises PreprocessError on failure. - """ - pass - - def compile( - self, - sources: Sequence[str | os.PathLike[str]], - output_dir: str | None = None, - macros: list[_Macro] | None = None, - include_dirs: list[str] | tuple[str, ...] | None = None, - debug: bool = False, - extra_preargs: list[str] | None = None, - extra_postargs: list[str] | None = None, - depends: list[str] | tuple[str, ...] | None = None, - ) -> list[str]: - """Compile one or more source files. - - 'sources' must be a list of filenames, most likely C/C++ - files, but in reality anything that can be handled by a - particular compiler and compiler class (eg. MSVCCompiler can - handle resource files in 'sources'). Return a list of object - filenames, one per source filename in 'sources'. Depending on - the implementation, not all source files will necessarily be - compiled, but all corresponding object filenames will be - returned. - - If 'output_dir' is given, object files will be put under it, while - retaining their original path component. That is, "foo/bar.c" - normally compiles to "foo/bar.o" (for a Unix implementation); if - 'output_dir' is "build", then it would compile to - "build/foo/bar.o". - - 'macros', if given, must be a list of macro definitions. A macro - definition is either a (name, value) 2-tuple or a (name,) 1-tuple. - The former defines a macro; if the value is None, the macro is - defined without an explicit value. The 1-tuple case undefines a - macro. Later definitions/redefinitions/ undefinitions take - precedence. - - 'include_dirs', if given, must be a list of strings, the - directories to add to the default include file search path for this - compilation only. - - 'debug' is a boolean; if true, the compiler will be instructed to - output debug symbols in (or alongside) the object file(s). - - 'extra_preargs' and 'extra_postargs' are implementation- dependent. - On platforms that have the notion of a command-line (e.g. Unix, - DOS/Windows), they are most likely lists of strings: extra - command-line arguments to prepend/append to the compiler command - line. On other platforms, consult the implementation class - documentation. In any event, they are intended as an escape hatch - for those occasions when the abstract compiler framework doesn't - cut the mustard. - - 'depends', if given, is a list of filenames that all targets - depend on. If a source file is older than any file in - depends, then the source file will be recompiled. This - supports dependency tracking, but only at a coarse - granularity. - - Raises CompileError on failure. - """ - # A concrete compiler class can either override this method - # entirely or implement _compile(). - macros, objects, extra_postargs, pp_opts, build = self._setup_compile( - output_dir, macros, include_dirs, sources, depends, extra_postargs - ) - cc_args = self._get_cc_args(pp_opts, debug, extra_preargs) - - for obj in objects: - try: - src, ext = build[obj] - except KeyError: - continue - self._compile(obj, src, ext, cc_args, extra_postargs, pp_opts) - - # Return *all* object filenames, not just the ones we just built. - return objects - - def _compile(self, obj, src, ext, cc_args, extra_postargs, pp_opts): - """Compile 'src' to product 'obj'.""" - # A concrete compiler class that does not override compile() - # should implement _compile(). - pass - - def create_static_lib( - self, - objects: list[str] | tuple[str, ...], - output_libname: str, - output_dir: str | None = None, - debug: bool = False, - target_lang: str | None = None, - ) -> None: - """Link a bunch of stuff together to create a static library file. - The "bunch of stuff" consists of the list of object files supplied - as 'objects', the extra object files supplied to - 'add_link_object()' and/or 'set_link_objects()', the libraries - supplied to 'add_library()' and/or 'set_libraries()', and the - libraries supplied as 'libraries' (if any). - - 'output_libname' should be a library name, not a filename; the - filename will be inferred from the library name. 'output_dir' is - the directory where the library file will be put. - - 'debug' is a boolean; if true, debugging information will be - included in the library (note that on most platforms, it is the - compile step where this matters: the 'debug' flag is included here - just for consistency). - - 'target_lang' is the target language for which the given objects - are being compiled. This allows specific linkage time treatment of - certain languages. - - Raises LibError on failure. - """ - pass - - # values for target_desc parameter in link() - SHARED_OBJECT = "shared_object" - SHARED_LIBRARY = "shared_library" - EXECUTABLE = "executable" - - def link( - self, - target_desc: str, - objects: list[str] | tuple[str, ...], - output_filename: str, - output_dir: str | None = None, - libraries: list[str] | tuple[str, ...] | None = None, - library_dirs: list[str] | tuple[str, ...] | None = None, - runtime_library_dirs: list[str] | tuple[str, ...] | None = None, - export_symbols: Iterable[str] | None = None, - debug: bool = False, - extra_preargs: list[str] | None = None, - extra_postargs: list[str] | None = None, - build_temp: str | os.PathLike[str] | None = None, - target_lang: str | None = None, - ): - """Link a bunch of stuff together to create an executable or - shared library file. - - The "bunch of stuff" consists of the list of object files supplied - as 'objects'. 'output_filename' should be a filename. If - 'output_dir' is supplied, 'output_filename' is relative to it - (i.e. 'output_filename' can provide directory components if - needed). - - 'libraries' is a list of libraries to link against. These are - library names, not filenames, since they're translated into - filenames in a platform-specific way (eg. "foo" becomes "libfoo.a" - on Unix and "foo.lib" on DOS/Windows). However, they can include a - directory component, which means the linker will look in that - specific directory rather than searching all the normal locations. - - 'library_dirs', if supplied, should be a list of directories to - search for libraries that were specified as bare library names - (ie. no directory component). These are on top of the system - default and those supplied to 'add_library_dir()' and/or - 'set_library_dirs()'. 'runtime_library_dirs' is a list of - directories that will be embedded into the shared library and used - to search for other shared libraries that *it* depends on at - run-time. (This may only be relevant on Unix.) - - 'export_symbols' is a list of symbols that the shared library will - export. (This appears to be relevant only on Windows.) - - 'debug' is as for 'compile()' and 'create_static_lib()', with the - slight distinction that it actually matters on most platforms (as - opposed to 'create_static_lib()', which includes a 'debug' flag - mostly for form's sake). - - 'extra_preargs' and 'extra_postargs' are as for 'compile()' (except - of course that they supply command-line arguments for the - particular linker being used). - - 'target_lang' is the target language for which the given objects - are being compiled. This allows specific linkage time treatment of - certain languages. - - Raises LinkError on failure. - """ - raise NotImplementedError - - # Old 'link_*()' methods, rewritten to use the new 'link()' method. - - def link_shared_lib( - self, - objects: list[str] | tuple[str, ...], - output_libname: str, - output_dir: str | None = None, - libraries: list[str] | tuple[str, ...] | None = None, - library_dirs: list[str] | tuple[str, ...] | None = None, - runtime_library_dirs: list[str] | tuple[str, ...] | None = None, - export_symbols: Iterable[str] | None = None, - debug: bool = False, - extra_preargs: list[str] | None = None, - extra_postargs: list[str] | None = None, - build_temp: str | os.PathLike[str] | None = None, - target_lang: str | None = None, - ): - self.link( - Compiler.SHARED_LIBRARY, - objects, - self.library_filename(output_libname, lib_type='shared'), - output_dir, - libraries, - library_dirs, - runtime_library_dirs, - export_symbols, - debug, - extra_preargs, - extra_postargs, - build_temp, - target_lang, - ) - - def link_shared_object( - self, - objects: list[str] | tuple[str, ...], - output_filename: str, - output_dir: str | None = None, - libraries: list[str] | tuple[str, ...] | None = None, - library_dirs: list[str] | tuple[str, ...] | None = None, - runtime_library_dirs: list[str] | tuple[str, ...] | None = None, - export_symbols: Iterable[str] | None = None, - debug: bool = False, - extra_preargs: list[str] | None = None, - extra_postargs: list[str] | None = None, - build_temp: str | os.PathLike[str] | None = None, - target_lang: str | None = None, - ): - self.link( - Compiler.SHARED_OBJECT, - objects, - output_filename, - output_dir, - libraries, - library_dirs, - runtime_library_dirs, - export_symbols, - debug, - extra_preargs, - extra_postargs, - build_temp, - target_lang, - ) - - def link_executable( - self, - objects: list[str] | tuple[str, ...], - output_progname: str, - output_dir: str | None = None, - libraries: list[str] | tuple[str, ...] | None = None, - library_dirs: list[str] | tuple[str, ...] | None = None, - runtime_library_dirs: list[str] | tuple[str, ...] | None = None, - debug: bool = False, - extra_preargs: list[str] | None = None, - extra_postargs: list[str] | None = None, - target_lang: str | None = None, - ): - self.link( - Compiler.EXECUTABLE, - objects, - self.executable_filename(output_progname), - output_dir, - libraries, - library_dirs, - runtime_library_dirs, - None, - debug, - extra_preargs, - extra_postargs, - None, - target_lang, - ) - - # -- Miscellaneous methods ----------------------------------------- - # These are all used by the 'gen_lib_options() function; there is - # no appropriate default implementation so subclasses should - # implement all of these. - - def library_dir_option(self, dir: str) -> str: - """Return the compiler option to add 'dir' to the list of - directories searched for libraries. - """ - raise NotImplementedError - - def runtime_library_dir_option(self, dir: str) -> str: - """Return the compiler option to add 'dir' to the list of - directories searched for runtime libraries. - """ - raise NotImplementedError - - def library_option(self, lib: str) -> str: - """Return the compiler option to add 'lib' to the list of libraries - linked into the shared library or executable. - """ - raise NotImplementedError - - def has_function( # noqa: C901 - self, - funcname: str, - includes: Iterable[str] | None = None, - include_dirs: list[str] | tuple[str, ...] | None = None, - libraries: list[str] | None = None, - library_dirs: list[str] | tuple[str, ...] | None = None, - ) -> bool: - """Return a boolean indicating whether funcname is provided as - a symbol on the current platform. The optional arguments can - be used to augment the compilation environment. - - The libraries argument is a list of flags to be passed to the - linker to make additional symbol definitions available for - linking. - - The includes and include_dirs arguments are deprecated. - Usually, supplying include files with function declarations - will cause function detection to fail even in cases where the - symbol is available for linking. - - """ - # this can't be included at module scope because it tries to - # import math which might not be available at that point - maybe - # the necessary logic should just be inlined? - import tempfile - - if includes is None: - includes = [] - else: - warnings.warn("includes is deprecated", DeprecationWarning) - if include_dirs is None: - include_dirs = [] - else: - warnings.warn("include_dirs is deprecated", DeprecationWarning) - if libraries is None: - libraries = [] - if library_dirs is None: - library_dirs = [] - fd, fname = tempfile.mkstemp(".c", funcname, text=True) - with os.fdopen(fd, "w", encoding='utf-8') as f: - for incl in includes: - f.write(f"""#include "{incl}"\n""") - if not includes: - # Use "char func(void);" as the prototype to follow - # what autoconf does. This prototype does not match - # any well-known function the compiler might recognize - # as a builtin, so this ends up as a true link test. - # Without a fake prototype, the test would need to - # know the exact argument types, and the has_function - # interface does not provide that level of information. - f.write( - f"""\ -#ifdef __cplusplus -extern "C" -#endif -char {funcname}(void); -""" - ) - f.write( - f"""\ -int main (int argc, char **argv) {{ - {funcname}(); - return 0; -}} -""" - ) - - try: - objects = self.compile([fname], include_dirs=include_dirs) - except CompileError: - return False - finally: - os.remove(fname) - - try: - self.link_executable( - objects, "a.out", libraries=libraries, library_dirs=library_dirs - ) - except (LinkError, TypeError): - return False - else: - os.remove( - self.executable_filename("a.out", output_dir=self.output_dir or '') - ) - finally: - for fn in objects: - os.remove(fn) - return True - - def find_library_file( - self, dirs: Iterable[str], lib: str, debug: bool = False - ) -> str | None: - """Search the specified list of directories for a static or shared - library file 'lib' and return the full path to that file. If - 'debug' true, look for a debugging version (if that makes sense on - the current platform). Return None if 'lib' wasn't found in any of - the specified directories. - """ - raise NotImplementedError - - # -- Filename generation methods ----------------------------------- - - # The default implementation of the filename generating methods are - # prejudiced towards the Unix/DOS/Windows view of the world: - # * object files are named by replacing the source file extension - # (eg. .c/.cpp -> .o/.obj) - # * library files (shared or static) are named by plugging the - # library name and extension into a format string, eg. - # "lib%s.%s" % (lib_name, ".a") for Unix static libraries - # * executables are named by appending an extension (possibly - # empty) to the program name: eg. progname + ".exe" for - # Windows - # - # To reduce redundant code, these methods expect to find - # several attributes in the current object (presumably defined - # as class attributes): - # * src_extensions - - # list of C/C++ source file extensions, eg. ['.c', '.cpp'] - # * obj_extension - - # object file extension, eg. '.o' or '.obj' - # * static_lib_extension - - # extension for static library files, eg. '.a' or '.lib' - # * shared_lib_extension - - # extension for shared library/object files, eg. '.so', '.dll' - # * static_lib_format - - # format string for generating static library filenames, - # eg. 'lib%s.%s' or '%s.%s' - # * shared_lib_format - # format string for generating shared library filenames - # (probably same as static_lib_format, since the extension - # is one of the intended parameters to the format string) - # * exe_extension - - # extension for executable files, eg. '' or '.exe' - - def object_filenames( - self, - source_filenames: Iterable[str | os.PathLike[str]], - strip_dir: bool = False, - output_dir: str | os.PathLike[str] | None = '', - ) -> list[str]: - if output_dir is None: - output_dir = '' - return list( - self._make_out_path(output_dir, strip_dir, src_name) - for src_name in source_filenames - ) - - @property - def out_extensions(self): - return dict.fromkeys(self.src_extensions, self.obj_extension) - - def _make_out_path(self, output_dir, strip_dir, src_name): - return self._make_out_path_exts( - output_dir, strip_dir, src_name, self.out_extensions - ) - - @classmethod - def _make_out_path_exts(cls, output_dir, strip_dir, src_name, extensions): - r""" - >>> exts = {'.c': '.o'} - >>> Compiler._make_out_path_exts('.', False, '/foo/bar.c', exts).replace('\\', '/') - './foo/bar.o' - >>> Compiler._make_out_path_exts('.', True, '/foo/bar.c', exts).replace('\\', '/') - './bar.o' - """ - src = pathlib.PurePath(src_name) - # Ensure base is relative to honor output_dir (python/cpython#37775). - base = cls._make_relative(src) - try: - new_ext = extensions[src.suffix] - except LookupError: - raise UnknownFileType(f"unknown file type '{src.suffix}' (from '{src}')") - if strip_dir: - base = pathlib.PurePath(base.name) - return os.path.join(output_dir, base.with_suffix(new_ext)) - - @staticmethod - def _make_relative(base: pathlib.Path): - return base.relative_to(base.anchor) - - @overload - def shared_object_filename( - self, - basename: str, - strip_dir: Literal[False] = False, - output_dir: str | os.PathLike[str] = "", - ) -> str: ... - @overload - def shared_object_filename( - self, - basename: str | os.PathLike[str], - strip_dir: Literal[True], - output_dir: str | os.PathLike[str] = "", - ) -> str: ... - def shared_object_filename( - self, - basename: str | os.PathLike[str], - strip_dir: bool = False, - output_dir: str | os.PathLike[str] = '', - ) -> str: - assert output_dir is not None - if strip_dir: - basename = os.path.basename(basename) - return os.path.join(output_dir, basename + self.shared_lib_extension) - - @overload - def executable_filename( - self, - basename: str, - strip_dir: Literal[False] = False, - output_dir: str | os.PathLike[str] = "", - ) -> str: ... - @overload - def executable_filename( - self, - basename: str | os.PathLike[str], - strip_dir: Literal[True], - output_dir: str | os.PathLike[str] = "", - ) -> str: ... - def executable_filename( - self, - basename: str | os.PathLike[str], - strip_dir: bool = False, - output_dir: str | os.PathLike[str] = '', - ) -> str: - assert output_dir is not None - if strip_dir: - basename = os.path.basename(basename) - return os.path.join(output_dir, basename + (self.exe_extension or '')) - - def library_filename( - self, - libname: str, - lib_type: str = "static", - strip_dir: bool = False, - output_dir: str | os.PathLike[str] = "", # or 'shared' - ): - assert output_dir is not None - expected = '"static", "shared", "dylib", "xcode_stub"' - if lib_type not in eval(expected): - raise ValueError(f"'lib_type' must be {expected}") - fmt = getattr(self, lib_type + "_lib_format") - ext = getattr(self, lib_type + "_lib_extension") - - dir, base = os.path.split(libname) - filename = fmt % (base, ext) - if strip_dir: - dir = '' - - return os.path.join(output_dir, dir, filename) - - # -- Utility methods ----------------------------------------------- - - def announce(self, msg: object, level: int = 1) -> None: - log.debug(msg) - - def debug_print(self, msg: object) -> None: - from distutils.debug import DEBUG - - if DEBUG: - print(msg) - - def warn(self, msg: object) -> None: - sys.stderr.write(f"warning: {msg}\n") - - def execute( - self, - func: Callable[[Unpack[_Ts]], object], - args: tuple[Unpack[_Ts]], - msg: object = None, - level: int = 1, - ) -> None: - execute(func, args, msg, self.dry_run) - - def spawn( - self, cmd: MutableSequence[bytes | str | os.PathLike[str]], **kwargs - ) -> None: - spawn(cmd, dry_run=self.dry_run, **kwargs) - - @overload - def move_file( - self, src: str | os.PathLike[str], dst: _StrPathT - ) -> _StrPathT | str: ... - @overload - def move_file( - self, src: bytes | os.PathLike[bytes], dst: _BytesPathT - ) -> _BytesPathT | bytes: ... - def move_file( - self, - src: str | os.PathLike[str] | bytes | os.PathLike[bytes], - dst: str | os.PathLike[str] | bytes | os.PathLike[bytes], - ) -> str | os.PathLike[str] | bytes | os.PathLike[bytes]: - return move_file(src, dst, dry_run=self.dry_run) - - def mkpath(self, name, mode=0o777): - mkpath(name, mode, dry_run=self.dry_run) - - -# Map a sys.platform/os.name ('posix', 'nt') to the default compiler -# type for that platform. Keys are interpreted as re match -# patterns. Order is important; platform mappings are preferred over -# OS names. -_default_compilers = ( - # Platform string mappings - # on a cygwin built python we can use gcc like an ordinary UNIXish - # compiler - ('cygwin.*', 'unix'), - ('zos', 'zos'), - # OS name mappings - ('posix', 'unix'), - ('nt', 'msvc'), -) - - -def get_default_compiler(osname: str | None = None, platform: str | None = None) -> str: - """Determine the default compiler to use for the given platform. - - osname should be one of the standard Python OS names (i.e. the - ones returned by os.name) and platform the common value - returned by sys.platform for the platform in question. - - The default values are os.name and sys.platform in case the - parameters are not given. - """ - if osname is None: - osname = os.name - if platform is None: - platform = sys.platform - # Mingw is a special case where sys.platform is 'win32' but we - # want to use the 'mingw32' compiler, so check it first - if is_mingw(): - return 'mingw32' - for pattern, compiler in _default_compilers: - if ( - re.match(pattern, platform) is not None - or re.match(pattern, osname) is not None - ): - return compiler - # Default to Unix compiler - return 'unix' - - -# Map compiler types to (module_name, class_name) pairs -- ie. where to -# find the code that implements an interface to this compiler. (The module -# is assumed to be in the 'distutils' package.) -compiler_class = { - 'unix': ('unixccompiler', 'UnixCCompiler', "standard UNIX-style compiler"), - 'msvc': ('_msvccompiler', 'MSVCCompiler', "Microsoft Visual C++"), - 'cygwin': ( - 'cygwinccompiler', - 'CygwinCCompiler', - "Cygwin port of GNU C Compiler for Win32", - ), - 'mingw32': ( - 'cygwinccompiler', - 'Mingw32CCompiler', - "Mingw32 port of GNU C Compiler for Win32", - ), - 'bcpp': ('bcppcompiler', 'BCPPCompiler', "Borland C++ Compiler"), - 'zos': ('zosccompiler', 'zOSCCompiler', 'IBM XL C/C++ Compilers'), -} - - -def show_compilers() -> None: - """Print list of available compilers (used by the "--help-compiler" - options to "build", "build_ext", "build_clib"). - """ - # XXX this "knows" that the compiler option it's describing is - # "--compiler", which just happens to be the case for the three - # commands that use it. - from distutils.fancy_getopt import FancyGetopt - - compilers = sorted( - ("compiler=" + compiler, None, compiler_class[compiler][2]) - for compiler in compiler_class.keys() - ) - pretty_printer = FancyGetopt(compilers) - pretty_printer.print_help("List of available compilers:") - - -def new_compiler( - plat: str | None = None, - compiler: str | None = None, - verbose: bool = False, - dry_run: bool = False, - force: bool = False, -) -> Compiler: - """Generate an instance of some CCompiler subclass for the supplied - platform/compiler combination. 'plat' defaults to 'os.name' - (eg. 'posix', 'nt'), and 'compiler' defaults to the default compiler - for that platform. Currently only 'posix' and 'nt' are supported, and - the default compilers are "traditional Unix interface" (UnixCCompiler - class) and Visual C++ (MSVCCompiler class). Note that it's perfectly - possible to ask for a Unix compiler object under Windows, and a - Microsoft compiler object under Unix -- if you supply a value for - 'compiler', 'plat' is ignored. - """ - if plat is None: - plat = os.name - - try: - if compiler is None: - compiler = get_default_compiler(plat) - - (module_name, class_name, long_description) = compiler_class[compiler] - except KeyError: - msg = f"don't know how to compile C/C++ code on platform '{plat}'" - if compiler is not None: - msg = msg + f" with '{compiler}' compiler" - raise DistutilsPlatformError(msg) - - try: - module_name = "distutils." + module_name - __import__(module_name) - module = sys.modules[module_name] - klass = vars(module)[class_name] - except ImportError: - raise DistutilsModuleError( - f"can't compile C/C++ code: unable to load module '{module_name}'" - ) - except KeyError: - raise DistutilsModuleError( - f"can't compile C/C++ code: unable to find class '{class_name}' " - f"in module '{module_name}'" - ) - - # XXX The None is necessary to preserve backwards compatibility - # with classes that expect verbose to be the first positional - # argument. - return klass(None, dry_run, force) - - -def gen_preprocess_options( - macros: Iterable[_Macro], include_dirs: Iterable[str] -) -> list[str]: - """Generate C pre-processor options (-D, -U, -I) as used by at least - two types of compilers: the typical Unix compiler and Visual C++. - 'macros' is the usual thing, a list of 1- or 2-tuples, where (name,) - means undefine (-U) macro 'name', and (name,value) means define (-D) - macro 'name' to 'value'. 'include_dirs' is just a list of directory - names to be added to the header file search path (-I). Returns a list - of command-line options suitable for either Unix compilers or Visual - C++. - """ - # XXX it would be nice (mainly aesthetic, and so we don't generate - # stupid-looking command lines) to go over 'macros' and eliminate - # redundant definitions/undefinitions (ie. ensure that only the - # latest mention of a particular macro winds up on the command - # line). I don't think it's essential, though, since most (all?) - # Unix C compilers only pay attention to the latest -D or -U - # mention of a macro on their command line. Similar situation for - # 'include_dirs'. I'm punting on both for now. Anyways, weeding out - # redundancies like this should probably be the province of - # CCompiler, since the data structures used are inherited from it - # and therefore common to all CCompiler classes. - pp_opts = [] - for macro in macros: - if not (isinstance(macro, tuple) and 1 <= len(macro) <= 2): - raise TypeError( - f"bad macro definition '{macro}': " - "each element of 'macros' list must be a 1- or 2-tuple" - ) - - if len(macro) == 1: # undefine this macro - pp_opts.append(f"-U{macro[0]}") - elif len(macro) == 2: - if macro[1] is None: # define with no explicit value - pp_opts.append(f"-D{macro[0]}") - else: - # XXX *don't* need to be clever about quoting the - # macro value here, because we're going to avoid the - # shell at all costs when we spawn the command! - pp_opts.append("-D{}={}".format(*macro)) - - pp_opts.extend(f"-I{dir}" for dir in include_dirs) - return pp_opts - - -def gen_lib_options( - compiler: Compiler, - library_dirs: Iterable[str], - runtime_library_dirs: Iterable[str], - libraries: Iterable[str], -) -> list[str]: - """Generate linker options for searching library directories and - linking with specific libraries. 'libraries' and 'library_dirs' are, - respectively, lists of library names (not filenames!) and search - directories. Returns a list of command-line options suitable for use - with some compiler (depending on the two format strings passed in). - """ - lib_opts = [compiler.library_dir_option(dir) for dir in library_dirs] - - for dir in runtime_library_dirs: - lib_opts.extend(always_iterable(compiler.runtime_library_dir_option(dir))) - - # XXX it's important that we *not* remove redundant library mentions! - # sometimes you really do have to say "-lfoo -lbar -lfoo" in order to - # resolve all symbols. I just hope we never have to say "-lfoo obj.o - # -lbar" to get things to work -- that's certainly a possibility, but a - # pretty nasty way to arrange your C code. - - for lib in libraries: - (lib_dir, lib_name) = os.path.split(lib) - if lib_dir: - lib_file = compiler.find_library_file([lib_dir], lib_name) - if lib_file: - lib_opts.append(lib_file) - else: - compiler.warn( - f"no library file corresponding to '{lib}' found (skipping)" - ) - else: - lib_opts.append(compiler.library_option(lib)) - return lib_opts +"""distutils.ccompiler + +Contains Compiler, an abstract base class that defines the interface +for the Distutils compiler abstraction model.""" + +from __future__ import annotations + +import os +import pathlib +import re +import sys +import warnings +from collections.abc import Callable, Iterable, MutableSequence, Sequence +from typing import ( + TYPE_CHECKING, + ClassVar, + Literal, + TypeVar, + Union, + overload, +) + +from more_itertools import always_iterable + +from ..._log import log +from ..._modified import newer_group +from ...dir_util import mkpath +from ...errors import ( + DistutilsModuleError, + DistutilsPlatformError, +) +from ...file_util import move_file +from ...spawn import spawn +from ...util import execute, is_mingw, split_quoted +from .errors import ( + CompileError, + LinkError, + UnknownFileType, +) + +if TYPE_CHECKING: + from typing_extensions import TypeAlias, TypeVarTuple, Unpack + + _Ts = TypeVarTuple("_Ts") + +_Macro: TypeAlias = Union[tuple[str], tuple[str, Union[str, None]]] +_StrPathT = TypeVar("_StrPathT", bound="str | os.PathLike[str]") +_BytesPathT = TypeVar("_BytesPathT", bound="bytes | os.PathLike[bytes]") + + +class Compiler: + """Abstract base class to define the interface that must be implemented + by real compiler classes. Also has some utility methods used by + several compiler classes. + + The basic idea behind a compiler abstraction class is that each + instance can be used for all the compile/link steps in building a + single project. Thus, attributes common to all of those compile and + link steps -- include directories, macros to define, libraries to link + against, etc. -- are attributes of the compiler instance. To allow for + variability in how individual files are treated, most of those + attributes may be varied on a per-compilation or per-link basis. + """ + + # 'compiler_type' is a class attribute that identifies this class. It + # keeps code that wants to know what kind of compiler it's dealing with + # from having to import all possible compiler classes just to do an + # 'isinstance'. In concrete CCompiler subclasses, 'compiler_type' + # should really, really be one of the keys of the 'compiler_class' + # dictionary (see below -- used by the 'new_compiler()' factory + # function) -- authors of new compiler interface classes are + # responsible for updating 'compiler_class'! + compiler_type: ClassVar[str] = None # type: ignore[assignment] + + # XXX things not handled by this compiler abstraction model: + # * client can't provide additional options for a compiler, + # e.g. warning, optimization, debugging flags. Perhaps this + # should be the domain of concrete compiler abstraction classes + # (UnixCCompiler, MSVCCompiler, etc.) -- or perhaps the base + # class should have methods for the common ones. + # * can't completely override the include or library searchg + # path, ie. no "cc -I -Idir1 -Idir2" or "cc -L -Ldir1 -Ldir2". + # I'm not sure how widely supported this is even by Unix + # compilers, much less on other platforms. And I'm even less + # sure how useful it is; maybe for cross-compiling, but + # support for that is a ways off. (And anyways, cross + # compilers probably have a dedicated binary with the + # right paths compiled in. I hope.) + # * can't do really freaky things with the library list/library + # dirs, e.g. "-Ldir1 -lfoo -Ldir2 -lfoo" to link against + # different versions of libfoo.a in different locations. I + # think this is useless without the ability to null out the + # library search path anyways. + + executables: ClassVar[dict] + + # Subclasses that rely on the standard filename generation methods + # implemented below should override these; see the comment near + # those methods ('object_filenames()' et. al.) for details: + src_extensions: ClassVar[list[str] | None] = None + obj_extension: ClassVar[str | None] = None + static_lib_extension: ClassVar[str | None] = None + shared_lib_extension: ClassVar[str | None] = None + static_lib_format: ClassVar[str | None] = None # format string + shared_lib_format: ClassVar[str | None] = None # prob. same as static_lib_format + exe_extension: ClassVar[str | None] = None + + # Default language settings. language_map is used to detect a source + # file or Extension target language, checking source filenames. + # language_order is used to detect the language precedence, when deciding + # what language to use when mixing source types. For example, if some + # extension has two files with ".c" extension, and one with ".cpp", it + # is still linked as c++. + language_map: ClassVar[dict[str, str]] = { + ".c": "c", + ".cc": "c++", + ".cpp": "c++", + ".cxx": "c++", + ".m": "objc", + } + language_order: ClassVar[list[str]] = ["c++", "objc", "c"] + + include_dirs: list[str] = [] + """ + include dirs specific to this compiler class + """ + + library_dirs: list[str] = [] + """ + library dirs specific to this compiler class + """ + + def __init__( + self, verbose: bool = False, dry_run: bool = False, force: bool = False + ) -> None: + self.dry_run = dry_run + self.force = force + self.verbose = verbose + + # 'output_dir': a common output directory for object, library, + # shared object, and shared library files + self.output_dir: str | None = None + + # 'macros': a list of macro definitions (or undefinitions). A + # macro definition is a 2-tuple (name, value), where the value is + # either a string or None (no explicit value). A macro + # undefinition is a 1-tuple (name,). + self.macros: list[_Macro] = [] + + # 'include_dirs': a list of directories to search for include files + self.include_dirs = [] + + # 'libraries': a list of libraries to include in any link + # (library names, not filenames: eg. "foo" not "libfoo.a") + self.libraries: list[str] = [] + + # 'library_dirs': a list of directories to search for libraries + self.library_dirs = [] + + # 'runtime_library_dirs': a list of directories to search for + # shared libraries/objects at runtime + self.runtime_library_dirs: list[str] = [] + + # 'objects': a list of object files (or similar, such as explicitly + # named library files) to include on any link + self.objects: list[str] = [] + + for key in self.executables.keys(): + self.set_executable(key, self.executables[key]) + + def set_executables(self, **kwargs: str) -> None: + """Define the executables (and options for them) that will be run + to perform the various stages of compilation. The exact set of + executables that may be specified here depends on the compiler + class (via the 'executables' class attribute), but most will have: + compiler the C/C++ compiler + linker_so linker used to create shared objects and libraries + linker_exe linker used to create binary executables + archiver static library creator + + On platforms with a command-line (Unix, DOS/Windows), each of these + is a string that will be split into executable name and (optional) + list of arguments. (Splitting the string is done similarly to how + Unix shells operate: words are delimited by spaces, but quotes and + backslashes can override this. See + 'distutils.util.split_quoted()'.) + """ + + # Note that some CCompiler implementation classes will define class + # attributes 'cpp', 'cc', etc. with hard-coded executable names; + # this is appropriate when a compiler class is for exactly one + # compiler/OS combination (eg. MSVCCompiler). Other compiler + # classes (UnixCCompiler, in particular) are driven by information + # discovered at run-time, since there are many different ways to do + # basically the same things with Unix C compilers. + + for key in kwargs: + if key not in self.executables: + raise ValueError( + f"unknown executable '{key}' for class {self.__class__.__name__}" + ) + self.set_executable(key, kwargs[key]) + + def set_executable(self, key, value): + if isinstance(value, str): + setattr(self, key, split_quoted(value)) + else: + setattr(self, key, value) + + def _find_macro(self, name): + i = 0 + for defn in self.macros: + if defn[0] == name: + return i + i += 1 + return None + + def _check_macro_definitions(self, definitions): + """Ensure that every element of 'definitions' is valid.""" + for defn in definitions: + self._check_macro_definition(*defn) + + def _check_macro_definition(self, defn): + """ + Raise a TypeError if defn is not valid. + + A valid definition is either a (name, value) 2-tuple or a (name,) tuple. + """ + if not isinstance(defn, tuple) or not self._is_valid_macro(*defn): + raise TypeError( + f"invalid macro definition '{defn}': " + "must be tuple (string,), (string, string), or (string, None)" + ) + + @staticmethod + def _is_valid_macro(name, value=None): + """ + A valid macro is a ``name : str`` and a ``value : str | None``. + + >>> Compiler._is_valid_macro('foo', None) + True + """ + return isinstance(name, str) and isinstance(value, (str, type(None))) + + # -- Bookkeeping methods ------------------------------------------- + + def define_macro(self, name: str, value: str | None = None) -> None: + """Define a preprocessor macro for all compilations driven by this + compiler object. The optional parameter 'value' should be a + string; if it is not supplied, then the macro will be defined + without an explicit value and the exact outcome depends on the + compiler used (XXX true? does ANSI say anything about this?) + """ + # Delete from the list of macro definitions/undefinitions if + # already there (so that this one will take precedence). + i = self._find_macro(name) + if i is not None: + del self.macros[i] + + self.macros.append((name, value)) + + def undefine_macro(self, name: str) -> None: + """Undefine a preprocessor macro for all compilations driven by + this compiler object. If the same macro is defined by + 'define_macro()' and undefined by 'undefine_macro()' the last call + takes precedence (including multiple redefinitions or + undefinitions). If the macro is redefined/undefined on a + per-compilation basis (ie. in the call to 'compile()'), then that + takes precedence. + """ + # Delete from the list of macro definitions/undefinitions if + # already there (so that this one will take precedence). + i = self._find_macro(name) + if i is not None: + del self.macros[i] + + undefn = (name,) + self.macros.append(undefn) + + def add_include_dir(self, dir: str) -> None: + """Add 'dir' to the list of directories that will be searched for + header files. The compiler is instructed to search directories in + the order in which they are supplied by successive calls to + 'add_include_dir()'. + """ + self.include_dirs.append(dir) + + def set_include_dirs(self, dirs: list[str]) -> None: + """Set the list of directories that will be searched to 'dirs' (a + list of strings). Overrides any preceding calls to + 'add_include_dir()'; subsequence calls to 'add_include_dir()' add + to the list passed to 'set_include_dirs()'. This does not affect + any list of standard include directories that the compiler may + search by default. + """ + self.include_dirs = dirs[:] + + def add_library(self, libname: str) -> None: + """Add 'libname' to the list of libraries that will be included in + all links driven by this compiler object. Note that 'libname' + should *not* be the name of a file containing a library, but the + name of the library itself: the actual filename will be inferred by + the linker, the compiler, or the compiler class (depending on the + platform). + + The linker will be instructed to link against libraries in the + order they were supplied to 'add_library()' and/or + 'set_libraries()'. It is perfectly valid to duplicate library + names; the linker will be instructed to link against libraries as + many times as they are mentioned. + """ + self.libraries.append(libname) + + def set_libraries(self, libnames: list[str]) -> None: + """Set the list of libraries to be included in all links driven by + this compiler object to 'libnames' (a list of strings). This does + not affect any standard system libraries that the linker may + include by default. + """ + self.libraries = libnames[:] + + def add_library_dir(self, dir: str) -> None: + """Add 'dir' to the list of directories that will be searched for + libraries specified to 'add_library()' and 'set_libraries()'. The + linker will be instructed to search for libraries in the order they + are supplied to 'add_library_dir()' and/or 'set_library_dirs()'. + """ + self.library_dirs.append(dir) + + def set_library_dirs(self, dirs: list[str]) -> None: + """Set the list of library search directories to 'dirs' (a list of + strings). This does not affect any standard library search path + that the linker may search by default. + """ + self.library_dirs = dirs[:] + + def add_runtime_library_dir(self, dir: str) -> None: + """Add 'dir' to the list of directories that will be searched for + shared libraries at runtime. + """ + self.runtime_library_dirs.append(dir) + + def set_runtime_library_dirs(self, dirs: list[str]) -> None: + """Set the list of directories to search for shared libraries at + runtime to 'dirs' (a list of strings). This does not affect any + standard search path that the runtime linker may search by + default. + """ + self.runtime_library_dirs = dirs[:] + + def add_link_object(self, object: str) -> None: + """Add 'object' to the list of object files (or analogues, such as + explicitly named library files or the output of "resource + compilers") to be included in every link driven by this compiler + object. + """ + self.objects.append(object) + + def set_link_objects(self, objects: list[str]) -> None: + """Set the list of object files (or analogues) to be included in + every link to 'objects'. This does not affect any standard object + files that the linker may include by default (such as system + libraries). + """ + self.objects = objects[:] + + # -- Private utility methods -------------------------------------- + # (here for the convenience of subclasses) + + # Helper method to prep compiler in subclass compile() methods + + def _setup_compile( + self, + outdir: str | None, + macros: list[_Macro] | None, + incdirs: list[str] | tuple[str, ...] | None, + sources, + depends, + extra, + ): + """Process arguments and decide which source files to compile.""" + outdir, macros, incdirs = self._fix_compile_args(outdir, macros, incdirs) + + if extra is None: + extra = [] + + # Get the list of expected output (object) files + objects = self.object_filenames(sources, strip_dir=False, output_dir=outdir) + assert len(objects) == len(sources) + + pp_opts = gen_preprocess_options(macros, incdirs) + + build = {} + for i in range(len(sources)): + src = sources[i] + obj = objects[i] + ext = os.path.splitext(src)[1] + self.mkpath(os.path.dirname(obj)) + build[obj] = (src, ext) + + return macros, objects, extra, pp_opts, build + + def _get_cc_args(self, pp_opts, debug, before): + # works for unixccompiler, cygwinccompiler + cc_args = pp_opts + ['-c'] + if debug: + cc_args[:0] = ['-g'] + if before: + cc_args[:0] = before + return cc_args + + def _fix_compile_args( + self, + output_dir: str | None, + macros: list[_Macro] | None, + include_dirs: list[str] | tuple[str, ...] | None, + ) -> tuple[str, list[_Macro], list[str]]: + """Typecheck and fix-up some of the arguments to the 'compile()' + method, and return fixed-up values. Specifically: if 'output_dir' + is None, replaces it with 'self.output_dir'; ensures that 'macros' + is a list, and augments it with 'self.macros'; ensures that + 'include_dirs' is a list, and augments it with 'self.include_dirs'. + Guarantees that the returned values are of the correct type, + i.e. for 'output_dir' either string or None, and for 'macros' and + 'include_dirs' either list or None. + """ + if output_dir is None: + output_dir = self.output_dir + elif not isinstance(output_dir, str): + raise TypeError("'output_dir' must be a string or None") + + if macros is None: + macros = list(self.macros) + elif isinstance(macros, list): + macros = macros + (self.macros or []) + else: + raise TypeError("'macros' (if supplied) must be a list of tuples") + + if include_dirs is None: + include_dirs = list(self.include_dirs) + elif isinstance(include_dirs, (list, tuple)): + include_dirs = list(include_dirs) + (self.include_dirs or []) + else: + raise TypeError("'include_dirs' (if supplied) must be a list of strings") + + # add include dirs for class + include_dirs += self.__class__.include_dirs + + return output_dir, macros, include_dirs + + def _prep_compile(self, sources, output_dir, depends=None): + """Decide which source files must be recompiled. + + Determine the list of object files corresponding to 'sources', + and figure out which ones really need to be recompiled. + Return a list of all object files and a dictionary telling + which source files can be skipped. + """ + # Get the list of expected output (object) files + objects = self.object_filenames(sources, output_dir=output_dir) + assert len(objects) == len(sources) + + # Return an empty dict for the "which source files can be skipped" + # return value to preserve API compatibility. + return objects, {} + + def _fix_object_args( + self, objects: list[str] | tuple[str, ...], output_dir: str | None + ) -> tuple[list[str], str]: + """Typecheck and fix up some arguments supplied to various methods. + Specifically: ensure that 'objects' is a list; if output_dir is + None, replace with self.output_dir. Return fixed versions of + 'objects' and 'output_dir'. + """ + if not isinstance(objects, (list, tuple)): + raise TypeError("'objects' must be a list or tuple of strings") + objects = list(objects) + + if output_dir is None: + output_dir = self.output_dir + elif not isinstance(output_dir, str): + raise TypeError("'output_dir' must be a string or None") + + return (objects, output_dir) + + def _fix_lib_args( + self, + libraries: list[str] | tuple[str, ...] | None, + library_dirs: list[str] | tuple[str, ...] | None, + runtime_library_dirs: list[str] | tuple[str, ...] | None, + ) -> tuple[list[str], list[str], list[str]]: + """Typecheck and fix up some of the arguments supplied to the + 'link_*' methods. Specifically: ensure that all arguments are + lists, and augment them with their permanent versions + (eg. 'self.libraries' augments 'libraries'). Return a tuple with + fixed versions of all arguments. + """ + if libraries is None: + libraries = list(self.libraries) + elif isinstance(libraries, (list, tuple)): + libraries = list(libraries) + (self.libraries or []) + else: + raise TypeError("'libraries' (if supplied) must be a list of strings") + + if library_dirs is None: + library_dirs = list(self.library_dirs) + elif isinstance(library_dirs, (list, tuple)): + library_dirs = list(library_dirs) + (self.library_dirs or []) + else: + raise TypeError("'library_dirs' (if supplied) must be a list of strings") + + # add library dirs for class + library_dirs += self.__class__.library_dirs + + if runtime_library_dirs is None: + runtime_library_dirs = list(self.runtime_library_dirs) + elif isinstance(runtime_library_dirs, (list, tuple)): + runtime_library_dirs = list(runtime_library_dirs) + ( + self.runtime_library_dirs or [] + ) + else: + raise TypeError( + "'runtime_library_dirs' (if supplied) must be a list of strings" + ) + + return (libraries, library_dirs, runtime_library_dirs) + + def _need_link(self, objects, output_file): + """Return true if we need to relink the files listed in 'objects' + to recreate 'output_file'. + """ + if self.force: + return True + else: + if self.dry_run: + newer = newer_group(objects, output_file, missing='newer') + else: + newer = newer_group(objects, output_file) + return newer + + def detect_language(self, sources: str | list[str]) -> str | None: + """Detect the language of a given file, or list of files. Uses + language_map, and language_order to do the job. + """ + if not isinstance(sources, list): + sources = [sources] + lang = None + index = len(self.language_order) + for source in sources: + base, ext = os.path.splitext(source) + extlang = self.language_map.get(ext) + try: + extindex = self.language_order.index(extlang) + if extindex < index: + lang = extlang + index = extindex + except ValueError: + pass + return lang + + # -- Worker methods ------------------------------------------------ + # (must be implemented by subclasses) + + def preprocess( + self, + source: str | os.PathLike[str], + output_file: str | os.PathLike[str] | None = None, + macros: list[_Macro] | None = None, + include_dirs: list[str] | tuple[str, ...] | None = None, + extra_preargs: list[str] | None = None, + extra_postargs: Iterable[str] | None = None, + ): + """Preprocess a single C/C++ source file, named in 'source'. + Output will be written to file named 'output_file', or stdout if + 'output_file' not supplied. 'macros' is a list of macro + definitions as for 'compile()', which will augment the macros set + with 'define_macro()' and 'undefine_macro()'. 'include_dirs' is a + list of directory names that will be added to the default list. + + Raises PreprocessError on failure. + """ + pass + + def compile( + self, + sources: Sequence[str | os.PathLike[str]], + output_dir: str | None = None, + macros: list[_Macro] | None = None, + include_dirs: list[str] | tuple[str, ...] | None = None, + debug: bool = False, + extra_preargs: list[str] | None = None, + extra_postargs: list[str] | None = None, + depends: list[str] | tuple[str, ...] | None = None, + ) -> list[str]: + """Compile one or more source files. + + 'sources' must be a list of filenames, most likely C/C++ + files, but in reality anything that can be handled by a + particular compiler and compiler class (eg. MSVCCompiler can + handle resource files in 'sources'). Return a list of object + filenames, one per source filename in 'sources'. Depending on + the implementation, not all source files will necessarily be + compiled, but all corresponding object filenames will be + returned. + + If 'output_dir' is given, object files will be put under it, while + retaining their original path component. That is, "foo/bar.c" + normally compiles to "foo/bar.o" (for a Unix implementation); if + 'output_dir' is "build", then it would compile to + "build/foo/bar.o". + + 'macros', if given, must be a list of macro definitions. A macro + definition is either a (name, value) 2-tuple or a (name,) 1-tuple. + The former defines a macro; if the value is None, the macro is + defined without an explicit value. The 1-tuple case undefines a + macro. Later definitions/redefinitions/ undefinitions take + precedence. + + 'include_dirs', if given, must be a list of strings, the + directories to add to the default include file search path for this + compilation only. + + 'debug' is a boolean; if true, the compiler will be instructed to + output debug symbols in (or alongside) the object file(s). + + 'extra_preargs' and 'extra_postargs' are implementation- dependent. + On platforms that have the notion of a command-line (e.g. Unix, + DOS/Windows), they are most likely lists of strings: extra + command-line arguments to prepend/append to the compiler command + line. On other platforms, consult the implementation class + documentation. In any event, they are intended as an escape hatch + for those occasions when the abstract compiler framework doesn't + cut the mustard. + + 'depends', if given, is a list of filenames that all targets + depend on. If a source file is older than any file in + depends, then the source file will be recompiled. This + supports dependency tracking, but only at a coarse + granularity. + + Raises CompileError on failure. + """ + # A concrete compiler class can either override this method + # entirely or implement _compile(). + macros, objects, extra_postargs, pp_opts, build = self._setup_compile( + output_dir, macros, include_dirs, sources, depends, extra_postargs + ) + cc_args = self._get_cc_args(pp_opts, debug, extra_preargs) + + for obj in objects: + try: + src, ext = build[obj] + except KeyError: + continue + self._compile(obj, src, ext, cc_args, extra_postargs, pp_opts) + + # Return *all* object filenames, not just the ones we just built. + return objects + + def _compile(self, obj, src, ext, cc_args, extra_postargs, pp_opts): + """Compile 'src' to product 'obj'.""" + # A concrete compiler class that does not override compile() + # should implement _compile(). + pass + + def create_static_lib( + self, + objects: list[str] | tuple[str, ...], + output_libname: str, + output_dir: str | None = None, + debug: bool = False, + target_lang: str | None = None, + ) -> None: + """Link a bunch of stuff together to create a static library file. + The "bunch of stuff" consists of the list of object files supplied + as 'objects', the extra object files supplied to + 'add_link_object()' and/or 'set_link_objects()', the libraries + supplied to 'add_library()' and/or 'set_libraries()', and the + libraries supplied as 'libraries' (if any). + + 'output_libname' should be a library name, not a filename; the + filename will be inferred from the library name. 'output_dir' is + the directory where the library file will be put. + + 'debug' is a boolean; if true, debugging information will be + included in the library (note that on most platforms, it is the + compile step where this matters: the 'debug' flag is included here + just for consistency). + + 'target_lang' is the target language for which the given objects + are being compiled. This allows specific linkage time treatment of + certain languages. + + Raises LibError on failure. + """ + pass + + # values for target_desc parameter in link() + SHARED_OBJECT = "shared_object" + SHARED_LIBRARY = "shared_library" + EXECUTABLE = "executable" + + def link( + self, + target_desc: str, + objects: list[str] | tuple[str, ...], + output_filename: str, + output_dir: str | None = None, + libraries: list[str] | tuple[str, ...] | None = None, + library_dirs: list[str] | tuple[str, ...] | None = None, + runtime_library_dirs: list[str] | tuple[str, ...] | None = None, + export_symbols: Iterable[str] | None = None, + debug: bool = False, + extra_preargs: list[str] | None = None, + extra_postargs: list[str] | None = None, + build_temp: str | os.PathLike[str] | None = None, + target_lang: str | None = None, + ): + """Link a bunch of stuff together to create an executable or + shared library file. + + The "bunch of stuff" consists of the list of object files supplied + as 'objects'. 'output_filename' should be a filename. If + 'output_dir' is supplied, 'output_filename' is relative to it + (i.e. 'output_filename' can provide directory components if + needed). + + 'libraries' is a list of libraries to link against. These are + library names, not filenames, since they're translated into + filenames in a platform-specific way (eg. "foo" becomes "libfoo.a" + on Unix and "foo.lib" on DOS/Windows). However, they can include a + directory component, which means the linker will look in that + specific directory rather than searching all the normal locations. + + 'library_dirs', if supplied, should be a list of directories to + search for libraries that were specified as bare library names + (ie. no directory component). These are on top of the system + default and those supplied to 'add_library_dir()' and/or + 'set_library_dirs()'. 'runtime_library_dirs' is a list of + directories that will be embedded into the shared library and used + to search for other shared libraries that *it* depends on at + run-time. (This may only be relevant on Unix.) + + 'export_symbols' is a list of symbols that the shared library will + export. (This appears to be relevant only on Windows.) + + 'debug' is as for 'compile()' and 'create_static_lib()', with the + slight distinction that it actually matters on most platforms (as + opposed to 'create_static_lib()', which includes a 'debug' flag + mostly for form's sake). + + 'extra_preargs' and 'extra_postargs' are as for 'compile()' (except + of course that they supply command-line arguments for the + particular linker being used). + + 'target_lang' is the target language for which the given objects + are being compiled. This allows specific linkage time treatment of + certain languages. + + Raises LinkError on failure. + """ + raise NotImplementedError + + # Old 'link_*()' methods, rewritten to use the new 'link()' method. + + def link_shared_lib( + self, + objects: list[str] | tuple[str, ...], + output_libname: str, + output_dir: str | None = None, + libraries: list[str] | tuple[str, ...] | None = None, + library_dirs: list[str] | tuple[str, ...] | None = None, + runtime_library_dirs: list[str] | tuple[str, ...] | None = None, + export_symbols: Iterable[str] | None = None, + debug: bool = False, + extra_preargs: list[str] | None = None, + extra_postargs: list[str] | None = None, + build_temp: str | os.PathLike[str] | None = None, + target_lang: str | None = None, + ): + self.link( + Compiler.SHARED_LIBRARY, + objects, + self.library_filename(output_libname, lib_type='shared'), + output_dir, + libraries, + library_dirs, + runtime_library_dirs, + export_symbols, + debug, + extra_preargs, + extra_postargs, + build_temp, + target_lang, + ) + + def link_shared_object( + self, + objects: list[str] | tuple[str, ...], + output_filename: str, + output_dir: str | None = None, + libraries: list[str] | tuple[str, ...] | None = None, + library_dirs: list[str] | tuple[str, ...] | None = None, + runtime_library_dirs: list[str] | tuple[str, ...] | None = None, + export_symbols: Iterable[str] | None = None, + debug: bool = False, + extra_preargs: list[str] | None = None, + extra_postargs: list[str] | None = None, + build_temp: str | os.PathLike[str] | None = None, + target_lang: str | None = None, + ): + self.link( + Compiler.SHARED_OBJECT, + objects, + output_filename, + output_dir, + libraries, + library_dirs, + runtime_library_dirs, + export_symbols, + debug, + extra_preargs, + extra_postargs, + build_temp, + target_lang, + ) + + def link_executable( + self, + objects: list[str] | tuple[str, ...], + output_progname: str, + output_dir: str | None = None, + libraries: list[str] | tuple[str, ...] | None = None, + library_dirs: list[str] | tuple[str, ...] | None = None, + runtime_library_dirs: list[str] | tuple[str, ...] | None = None, + debug: bool = False, + extra_preargs: list[str] | None = None, + extra_postargs: list[str] | None = None, + target_lang: str | None = None, + ): + self.link( + Compiler.EXECUTABLE, + objects, + self.executable_filename(output_progname), + output_dir, + libraries, + library_dirs, + runtime_library_dirs, + None, + debug, + extra_preargs, + extra_postargs, + None, + target_lang, + ) + + # -- Miscellaneous methods ----------------------------------------- + # These are all used by the 'gen_lib_options() function; there is + # no appropriate default implementation so subclasses should + # implement all of these. + + def library_dir_option(self, dir: str) -> str: + """Return the compiler option to add 'dir' to the list of + directories searched for libraries. + """ + raise NotImplementedError + + def runtime_library_dir_option(self, dir: str) -> str | list[str]: + """Return the compiler option to add 'dir' to the list of + directories searched for runtime libraries. + """ + raise NotImplementedError + + def library_option(self, lib: str) -> str: + """Return the compiler option to add 'lib' to the list of libraries + linked into the shared library or executable. + """ + raise NotImplementedError + + def has_function( # noqa: C901 + self, + funcname: str, + includes: Iterable[str] | None = None, + include_dirs: list[str] | tuple[str, ...] | None = None, + libraries: list[str] | None = None, + library_dirs: list[str] | tuple[str, ...] | None = None, + ) -> bool: + """Return a boolean indicating whether funcname is provided as + a symbol on the current platform. The optional arguments can + be used to augment the compilation environment. + + The libraries argument is a list of flags to be passed to the + linker to make additional symbol definitions available for + linking. + + The includes and include_dirs arguments are deprecated. + Usually, supplying include files with function declarations + will cause function detection to fail even in cases where the + symbol is available for linking. + + """ + # this can't be included at module scope because it tries to + # import math which might not be available at that point - maybe + # the necessary logic should just be inlined? + import tempfile + + if includes is None: + includes = [] + else: + warnings.warn("includes is deprecated", DeprecationWarning) + if include_dirs is None: + include_dirs = [] + else: + warnings.warn("include_dirs is deprecated", DeprecationWarning) + if libraries is None: + libraries = [] + if library_dirs is None: + library_dirs = [] + fd, fname = tempfile.mkstemp(".c", funcname, text=True) + with os.fdopen(fd, "w", encoding='utf-8') as f: + for incl in includes: + f.write(f"""#include "{incl}"\n""") + if not includes: + # Use "char func(void);" as the prototype to follow + # what autoconf does. This prototype does not match + # any well-known function the compiler might recognize + # as a builtin, so this ends up as a true link test. + # Without a fake prototype, the test would need to + # know the exact argument types, and the has_function + # interface does not provide that level of information. + f.write( + f"""\ +#ifdef __cplusplus +extern "C" +#endif +char {funcname}(void); +""" + ) + f.write( + f"""\ +int main (int argc, char **argv) {{ + {funcname}(); + return 0; +}} +""" + ) + + try: + objects = self.compile([fname], include_dirs=include_dirs) + except CompileError: + return False + finally: + os.remove(fname) + + try: + self.link_executable( + objects, "a.out", libraries=libraries, library_dirs=library_dirs + ) + except (LinkError, TypeError): + return False + else: + os.remove( + self.executable_filename("a.out", output_dir=self.output_dir or '') + ) + finally: + for fn in objects: + os.remove(fn) + return True + + def find_library_file( + self, dirs: Iterable[str], lib: str, debug: bool = False + ) -> str | None: + """Search the specified list of directories for a static or shared + library file 'lib' and return the full path to that file. If + 'debug' true, look for a debugging version (if that makes sense on + the current platform). Return None if 'lib' wasn't found in any of + the specified directories. + """ + raise NotImplementedError + + # -- Filename generation methods ----------------------------------- + + # The default implementation of the filename generating methods are + # prejudiced towards the Unix/DOS/Windows view of the world: + # * object files are named by replacing the source file extension + # (eg. .c/.cpp -> .o/.obj) + # * library files (shared or static) are named by plugging the + # library name and extension into a format string, eg. + # "lib%s.%s" % (lib_name, ".a") for Unix static libraries + # * executables are named by appending an extension (possibly + # empty) to the program name: eg. progname + ".exe" for + # Windows + # + # To reduce redundant code, these methods expect to find + # several attributes in the current object (presumably defined + # as class attributes): + # * src_extensions - + # list of C/C++ source file extensions, eg. ['.c', '.cpp'] + # * obj_extension - + # object file extension, eg. '.o' or '.obj' + # * static_lib_extension - + # extension for static library files, eg. '.a' or '.lib' + # * shared_lib_extension - + # extension for shared library/object files, eg. '.so', '.dll' + # * static_lib_format - + # format string for generating static library filenames, + # eg. 'lib%s.%s' or '%s.%s' + # * shared_lib_format + # format string for generating shared library filenames + # (probably same as static_lib_format, since the extension + # is one of the intended parameters to the format string) + # * exe_extension - + # extension for executable files, eg. '' or '.exe' + + def object_filenames( + self, + source_filenames: Iterable[str | os.PathLike[str]], + strip_dir: bool = False, + output_dir: str | os.PathLike[str] | None = '', + ) -> list[str]: + if output_dir is None: + output_dir = '' + return list( + self._make_out_path(output_dir, strip_dir, src_name) + for src_name in source_filenames + ) + + @property + def out_extensions(self): + return dict.fromkeys(self.src_extensions, self.obj_extension) + + def _make_out_path(self, output_dir, strip_dir, src_name): + return self._make_out_path_exts( + output_dir, strip_dir, src_name, self.out_extensions + ) + + @classmethod + def _make_out_path_exts(cls, output_dir, strip_dir, src_name, extensions): + r""" + >>> exts = {'.c': '.o'} + >>> Compiler._make_out_path_exts('.', False, '/foo/bar.c', exts).replace('\\', '/') + './foo/bar.o' + >>> Compiler._make_out_path_exts('.', True, '/foo/bar.c', exts).replace('\\', '/') + './bar.o' + """ + src = pathlib.PurePath(src_name) + # Ensure base is relative to honor output_dir (python/cpython#37775). + base = cls._make_relative(src) + try: + new_ext = extensions[src.suffix] + except LookupError: + raise UnknownFileType(f"unknown file type '{src.suffix}' (from '{src}')") + if strip_dir: + base = pathlib.PurePath(base.name) + return os.path.join(output_dir, base.with_suffix(new_ext)) + + @staticmethod + def _make_relative(base: pathlib.Path): + return base.relative_to(base.anchor) + + @overload + def shared_object_filename( + self, + basename: str, + strip_dir: Literal[False] = False, + output_dir: str | os.PathLike[str] = "", + ) -> str: ... + @overload + def shared_object_filename( + self, + basename: str | os.PathLike[str], + strip_dir: Literal[True], + output_dir: str | os.PathLike[str] = "", + ) -> str: ... + def shared_object_filename( + self, + basename: str | os.PathLike[str], + strip_dir: bool = False, + output_dir: str | os.PathLike[str] = '', + ) -> str: + assert output_dir is not None + if strip_dir: + basename = os.path.basename(basename) + return os.path.join(output_dir, basename + self.shared_lib_extension) + + @overload + def executable_filename( + self, + basename: str, + strip_dir: Literal[False] = False, + output_dir: str | os.PathLike[str] = "", + ) -> str: ... + @overload + def executable_filename( + self, + basename: str | os.PathLike[str], + strip_dir: Literal[True], + output_dir: str | os.PathLike[str] = "", + ) -> str: ... + def executable_filename( + self, + basename: str | os.PathLike[str], + strip_dir: bool = False, + output_dir: str | os.PathLike[str] = '', + ) -> str: + assert output_dir is not None + if strip_dir: + basename = os.path.basename(basename) + return os.path.join(output_dir, basename + (self.exe_extension or '')) + + def library_filename( + self, + libname: str, + lib_type: str = "static", + strip_dir: bool = False, + output_dir: str | os.PathLike[str] = "", # or 'shared' + ): + assert output_dir is not None + expected = '"static", "shared", "dylib", "xcode_stub"' + if lib_type not in eval(expected): + raise ValueError(f"'lib_type' must be {expected}") + fmt = getattr(self, lib_type + "_lib_format") + ext = getattr(self, lib_type + "_lib_extension") + + dir, base = os.path.split(libname) + filename = fmt % (base, ext) + if strip_dir: + dir = '' + + return os.path.join(output_dir, dir, filename) + + # -- Utility methods ----------------------------------------------- + + def announce(self, msg: object, level: int = 1) -> None: + log.debug(msg) + + def debug_print(self, msg: object) -> None: + from distutils.debug import DEBUG + + if DEBUG: + print(msg) + + def warn(self, msg: object) -> None: + sys.stderr.write(f"warning: {msg}\n") + + def execute( + self, + func: Callable[[Unpack[_Ts]], object], + args: tuple[Unpack[_Ts]], + msg: object = None, + level: int = 1, + ) -> None: + execute(func, args, msg, self.dry_run) + + def spawn( + self, cmd: MutableSequence[bytes | str | os.PathLike[str]], **kwargs + ) -> None: + spawn(cmd, dry_run=self.dry_run, **kwargs) + + @overload + def move_file( + self, src: str | os.PathLike[str], dst: _StrPathT + ) -> _StrPathT | str: ... + @overload + def move_file( + self, src: bytes | os.PathLike[bytes], dst: _BytesPathT + ) -> _BytesPathT | bytes: ... + def move_file( + self, + src: str | os.PathLike[str] | bytes | os.PathLike[bytes], + dst: str | os.PathLike[str] | bytes | os.PathLike[bytes], + ) -> str | os.PathLike[str] | bytes | os.PathLike[bytes]: + return move_file(src, dst, dry_run=self.dry_run) + + def mkpath(self, name, mode=0o777): + mkpath(name, mode, dry_run=self.dry_run) + + +# Map a sys.platform/os.name ('posix', 'nt') to the default compiler +# type for that platform. Keys are interpreted as re match +# patterns. Order is important; platform mappings are preferred over +# OS names. +_default_compilers = ( + # Platform string mappings + # on a cygwin built python we can use gcc like an ordinary UNIXish + # compiler + ('cygwin.*', 'unix'), + ('zos', 'zos'), + # OS name mappings + ('posix', 'unix'), + ('nt', 'msvc'), +) + + +def get_default_compiler(osname: str | None = None, platform: str | None = None) -> str: + """Determine the default compiler to use for the given platform. + + osname should be one of the standard Python OS names (i.e. the + ones returned by os.name) and platform the common value + returned by sys.platform for the platform in question. + + The default values are os.name and sys.platform in case the + parameters are not given. + """ + if osname is None: + osname = os.name + if platform is None: + platform = sys.platform + # Mingw is a special case where sys.platform is 'win32' but we + # want to use the 'mingw32' compiler, so check it first + if is_mingw(): + return 'mingw32' + for pattern, compiler in _default_compilers: + if ( + re.match(pattern, platform) is not None + or re.match(pattern, osname) is not None + ): + return compiler + # Default to Unix compiler + return 'unix' + + +# Map compiler types to (module_name, class_name) pairs -- ie. where to +# find the code that implements an interface to this compiler. (The module +# is assumed to be in the 'distutils' package.) +compiler_class = { + 'unix': ('unixccompiler', 'UnixCCompiler', "standard UNIX-style compiler"), + 'msvc': ('_msvccompiler', 'MSVCCompiler', "Microsoft Visual C++"), + 'cygwin': ( + 'cygwinccompiler', + 'CygwinCCompiler', + "Cygwin port of GNU C Compiler for Win32", + ), + 'mingw32': ( + 'cygwinccompiler', + 'Mingw32CCompiler', + "Mingw32 port of GNU C Compiler for Win32", + ), + 'bcpp': ('bcppcompiler', 'BCPPCompiler', "Borland C++ Compiler"), + 'zos': ('zosccompiler', 'zOSCCompiler', 'IBM XL C/C++ Compilers'), +} + + +def show_compilers() -> None: + """Print list of available compilers (used by the "--help-compiler" + options to "build", "build_ext", "build_clib"). + """ + # XXX this "knows" that the compiler option it's describing is + # "--compiler", which just happens to be the case for the three + # commands that use it. + from distutils.fancy_getopt import FancyGetopt + + compilers = sorted( + ("compiler=" + compiler, None, compiler_class[compiler][2]) + for compiler in compiler_class.keys() + ) + pretty_printer = FancyGetopt(compilers) + pretty_printer.print_help("List of available compilers:") + + +def new_compiler( + plat: str | None = None, + compiler: str | None = None, + verbose: bool = False, + dry_run: bool = False, + force: bool = False, +) -> Compiler: + """Generate an instance of some CCompiler subclass for the supplied + platform/compiler combination. 'plat' defaults to 'os.name' + (eg. 'posix', 'nt'), and 'compiler' defaults to the default compiler + for that platform. Currently only 'posix' and 'nt' are supported, and + the default compilers are "traditional Unix interface" (UnixCCompiler + class) and Visual C++ (MSVCCompiler class). Note that it's perfectly + possible to ask for a Unix compiler object under Windows, and a + Microsoft compiler object under Unix -- if you supply a value for + 'compiler', 'plat' is ignored. + """ + if plat is None: + plat = os.name + + try: + if compiler is None: + compiler = get_default_compiler(plat) + + (module_name, class_name, long_description) = compiler_class[compiler] + except KeyError: + msg = f"don't know how to compile C/C++ code on platform '{plat}'" + if compiler is not None: + msg = msg + f" with '{compiler}' compiler" + raise DistutilsPlatformError(msg) + + try: + module_name = "distutils." + module_name + __import__(module_name) + module = sys.modules[module_name] + klass = vars(module)[class_name] + except ImportError: + raise DistutilsModuleError( + f"can't compile C/C++ code: unable to load module '{module_name}'" + ) + except KeyError: + raise DistutilsModuleError( + f"can't compile C/C++ code: unable to find class '{class_name}' " + f"in module '{module_name}'" + ) + + # XXX The None is necessary to preserve backwards compatibility + # with classes that expect verbose to be the first positional + # argument. + return klass(None, dry_run, force) + + +def gen_preprocess_options( + macros: Iterable[_Macro], include_dirs: Iterable[str] +) -> list[str]: + """Generate C pre-processor options (-D, -U, -I) as used by at least + two types of compilers: the typical Unix compiler and Visual C++. + 'macros' is the usual thing, a list of 1- or 2-tuples, where (name,) + means undefine (-U) macro 'name', and (name,value) means define (-D) + macro 'name' to 'value'. 'include_dirs' is just a list of directory + names to be added to the header file search path (-I). Returns a list + of command-line options suitable for either Unix compilers or Visual + C++. + """ + # XXX it would be nice (mainly aesthetic, and so we don't generate + # stupid-looking command lines) to go over 'macros' and eliminate + # redundant definitions/undefinitions (ie. ensure that only the + # latest mention of a particular macro winds up on the command + # line). I don't think it's essential, though, since most (all?) + # Unix C compilers only pay attention to the latest -D or -U + # mention of a macro on their command line. Similar situation for + # 'include_dirs'. I'm punting on both for now. Anyways, weeding out + # redundancies like this should probably be the province of + # CCompiler, since the data structures used are inherited from it + # and therefore common to all CCompiler classes. + pp_opts = [] + for macro in macros: + if not (isinstance(macro, tuple) and 1 <= len(macro) <= 2): + raise TypeError( + f"bad macro definition '{macro}': " + "each element of 'macros' list must be a 1- or 2-tuple" + ) + + if len(macro) == 1: # undefine this macro + pp_opts.append(f"-U{macro[0]}") + elif len(macro) == 2: + if macro[1] is None: # define with no explicit value + pp_opts.append(f"-D{macro[0]}") + else: + # XXX *don't* need to be clever about quoting the + # macro value here, because we're going to avoid the + # shell at all costs when we spawn the command! + pp_opts.append("-D{}={}".format(*macro)) + + pp_opts.extend(f"-I{dir}" for dir in include_dirs) + return pp_opts + + +def gen_lib_options( + compiler: Compiler, + library_dirs: Iterable[str], + runtime_library_dirs: Iterable[str], + libraries: Iterable[str], +) -> list[str]: + """Generate linker options for searching library directories and + linking with specific libraries. 'libraries' and 'library_dirs' are, + respectively, lists of library names (not filenames!) and search + directories. Returns a list of command-line options suitable for use + with some compiler (depending on the two format strings passed in). + """ + lib_opts = [compiler.library_dir_option(dir) for dir in library_dirs] + + for dir in runtime_library_dirs: + lib_opts.extend(always_iterable(compiler.runtime_library_dir_option(dir))) + + # XXX it's important that we *not* remove redundant library mentions! + # sometimes you really do have to say "-lfoo -lbar -lfoo" in order to + # resolve all symbols. I just hope we never have to say "-lfoo obj.o + # -lbar" to get things to work -- that's certainly a possibility, but a + # pretty nasty way to arrange your C code. + + for lib in libraries: + (lib_dir, lib_name) = os.path.split(lib) + if lib_dir: + lib_file = compiler.find_library_file([lib_dir], lib_name) + if lib_file: + lib_opts.append(lib_file) + else: + compiler.warn( + f"no library file corresponding to '{lib}' found (skipping)" + ) + else: + lib_opts.append(compiler.library_option(lib)) + return lib_opts diff --git a/setuptools/_distutils/compilers/C/cygwin.py b/setuptools/_distutils/compilers/C/cygwin.py index bfabbb306e..4a1f254d78 100644 --- a/setuptools/_distutils/compilers/C/cygwin.py +++ b/setuptools/_distutils/compilers/C/cygwin.py @@ -6,6 +6,8 @@ cygwin in no-cygwin mode). """ +from __future__ import annotations + import copy import os import pathlib @@ -327,7 +329,7 @@ def check_config_h(): return code, f"{fn!r} {mention_inflected} {substring!r}" -def is_cygwincc(cc): +def is_cygwincc(cc: str | shlex._ShlexInstream) -> bool: """Try to determine if the compiler that would be used is from cygwin.""" out_string = check_output(shlex.split(cc) + ['-dumpmachine']) return out_string.strip().endswith(b'cygwin') diff --git a/setuptools/_distutils/compilers/C/msvc.py b/setuptools/_distutils/compilers/C/msvc.py index 6db062a9e7..6195fb828d 100644 --- a/setuptools/_distutils/compilers/C/msvc.py +++ b/setuptools/_distutils/compilers/C/msvc.py @@ -1,614 +1,614 @@ -"""distutils._msvccompiler - -Contains MSVCCompiler, an implementation of the abstract CCompiler class -for Microsoft Visual Studio 2015. - -This module requires VS 2015 or later. -""" - -# Written by Perry Stoll -# hacked by Robin Becker and Thomas Heller to do a better job of -# finding DevStudio (through the registry) -# ported to VS 2005 and VS 2008 by Christian Heimes -# ported to VS 2015 by Steve Dower -from __future__ import annotations - -import contextlib -import os -import subprocess -import unittest.mock as mock -import warnings -from collections.abc import Iterable - -with contextlib.suppress(ImportError): - import winreg - -from itertools import count - -from ..._log import log -from ...errors import ( - DistutilsExecError, - DistutilsPlatformError, -) -from ...util import get_host_platform, get_platform -from . import base -from .base import gen_lib_options -from .errors import ( - CompileError, - LibError, - LinkError, -) - - -def _find_vc2015(): - try: - key = winreg.OpenKeyEx( - winreg.HKEY_LOCAL_MACHINE, - r"Software\Microsoft\VisualStudio\SxS\VC7", - access=winreg.KEY_READ | winreg.KEY_WOW64_32KEY, - ) - except OSError: - log.debug("Visual C++ is not registered") - return None, None - - best_version = 0 - best_dir = None - with key: - for i in count(): - try: - v, vc_dir, vt = winreg.EnumValue(key, i) - except OSError: - break - if v and vt == winreg.REG_SZ and os.path.isdir(vc_dir): - try: - version = int(float(v)) - except (ValueError, TypeError): - continue - if version >= 14 and version > best_version: - best_version, best_dir = version, vc_dir - return best_version, best_dir - - -def _find_vc2017(): - """Returns "15, path" based on the result of invoking vswhere.exe - If no install is found, returns "None, None" - - The version is returned to avoid unnecessarily changing the function - result. It may be ignored when the path is not None. - - If vswhere.exe is not available, by definition, VS 2017 is not - installed. - """ - root = os.environ.get("ProgramFiles(x86)") or os.environ.get("ProgramFiles") - if not root: - return None, None - - variant = 'arm64' if get_platform() == 'win-arm64' else 'x86.x64' - suitable_components = ( - f"Microsoft.VisualStudio.Component.VC.Tools.{variant}", - "Microsoft.VisualStudio.Workload.WDExpress", - ) - - for component in suitable_components: - # Workaround for `-requiresAny` (only available on VS 2017 > 15.6) - with contextlib.suppress( - subprocess.CalledProcessError, OSError, UnicodeDecodeError - ): - path = ( - subprocess.check_output([ - os.path.join( - root, "Microsoft Visual Studio", "Installer", "vswhere.exe" - ), - "-latest", - "-prerelease", - "-requires", - component, - "-property", - "installationPath", - "-products", - "*", - ]) - .decode(encoding="mbcs", errors="strict") - .strip() - ) - - path = os.path.join(path, "VC", "Auxiliary", "Build") - if os.path.isdir(path): - return 15, path - - return None, None # no suitable component found - - -PLAT_SPEC_TO_RUNTIME = { - 'x86': 'x86', - 'x86_amd64': 'x64', - 'x86_arm': 'arm', - 'x86_arm64': 'arm64', -} - - -def _find_vcvarsall(plat_spec): - # bpo-38597: Removed vcruntime return value - _, best_dir = _find_vc2017() - - if not best_dir: - best_version, best_dir = _find_vc2015() - - if not best_dir: - log.debug("No suitable Visual C++ version found") - return None, None - - vcvarsall = os.path.join(best_dir, "vcvarsall.bat") - if not os.path.isfile(vcvarsall): - log.debug("%s cannot be found", vcvarsall) - return None, None - - return vcvarsall, None - - -def _get_vc_env(plat_spec): - if os.getenv("DISTUTILS_USE_SDK"): - return {key.lower(): value for key, value in os.environ.items()} - - vcvarsall, _ = _find_vcvarsall(plat_spec) - if not vcvarsall: - raise DistutilsPlatformError( - 'Microsoft Visual C++ 14.0 or greater is required. ' - 'Get it with "Microsoft C++ Build Tools": ' - 'https://visualstudio.microsoft.com/visual-cpp-build-tools/' - ) - - try: - out = subprocess.check_output( - f'cmd /u /c "{vcvarsall}" {plat_spec} && set', - stderr=subprocess.STDOUT, - ).decode('utf-16le', errors='replace') - except subprocess.CalledProcessError as exc: - log.error(exc.output) - raise DistutilsPlatformError(f"Error executing {exc.cmd}") - - env = { - key.lower(): value - for key, _, value in (line.partition('=') for line in out.splitlines()) - if key and value - } - - return env - - -def _find_exe(exe, paths=None): - """Return path to an MSVC executable program. - - Tries to find the program in several places: first, one of the - MSVC program search paths from the registry; next, the directories - in the PATH environment variable. If any of those work, return an - absolute path that is known to exist. If none of them work, just - return the original program name, 'exe'. - """ - if not paths: - paths = os.getenv('path').split(os.pathsep) - for p in paths: - fn = os.path.join(os.path.abspath(p), exe) - if os.path.isfile(fn): - return fn - return exe - - -_vcvars_names = { - 'win32': 'x86', - 'win-amd64': 'amd64', - 'win-arm32': 'arm', - 'win-arm64': 'arm64', -} - - -def _get_vcvars_spec(host_platform, platform): - """ - Given a host platform and platform, determine the spec for vcvarsall. - - Uses the native MSVC host if the host platform would need expensive - emulation for x86. - - >>> _get_vcvars_spec('win-arm64', 'win32') - 'arm64_x86' - >>> _get_vcvars_spec('win-arm64', 'win-amd64') - 'arm64_amd64' - - Otherwise, always cross-compile from x86 to work with the - lighter-weight MSVC installs that do not include native 64-bit tools. - - >>> _get_vcvars_spec('win32', 'win32') - 'x86' - >>> _get_vcvars_spec('win-arm32', 'win-arm32') - 'x86_arm' - >>> _get_vcvars_spec('win-amd64', 'win-arm64') - 'x86_arm64' - """ - if host_platform != 'win-arm64': - host_platform = 'win32' - vc_hp = _vcvars_names[host_platform] - vc_plat = _vcvars_names[platform] - return vc_hp if vc_hp == vc_plat else f'{vc_hp}_{vc_plat}' - - -class Compiler(base.Compiler): - """Concrete class that implements an interface to Microsoft Visual C++, - as defined by the CCompiler abstract class.""" - - compiler_type = 'msvc' - - # Just set this so CCompiler's constructor doesn't barf. We currently - # don't use the 'set_executables()' bureaucracy provided by CCompiler, - # as it really isn't necessary for this sort of single-compiler class. - # Would be nice to have a consistent interface with UnixCCompiler, - # though, so it's worth thinking about. - executables = {} - - # Private class data (need to distinguish C from C++ source for compiler) - _c_extensions = ['.c'] - _cpp_extensions = ['.cc', '.cpp', '.cxx'] - _rc_extensions = ['.rc'] - _mc_extensions = ['.mc'] - - # Needed for the filename generation methods provided by the - # base class, CCompiler. - src_extensions = _c_extensions + _cpp_extensions + _rc_extensions + _mc_extensions - res_extension = '.res' - obj_extension = '.obj' - static_lib_extension = '.lib' - shared_lib_extension = '.dll' - static_lib_format = shared_lib_format = '%s%s' - exe_extension = '.exe' - - def __init__(self, verbose=False, dry_run=False, force=False) -> None: - super().__init__(verbose, dry_run, force) - # target platform (.plat_name is consistent with 'bdist') - self.plat_name = None - self.initialized = False - - @classmethod - def _configure(cls, vc_env): - """ - Set class-level include/lib dirs. - """ - cls.include_dirs = cls._parse_path(vc_env.get('include', '')) - cls.library_dirs = cls._parse_path(vc_env.get('lib', '')) - - @staticmethod - def _parse_path(val): - return [dir.rstrip(os.sep) for dir in val.split(os.pathsep) if dir] - - def initialize(self, plat_name: str | None = None) -> None: - # multi-init means we would need to check platform same each time... - assert not self.initialized, "don't init multiple times" - if plat_name is None: - plat_name = get_platform() - # sanity check for platforms to prevent obscure errors later. - if plat_name not in _vcvars_names: - raise DistutilsPlatformError( - f"--plat-name must be one of {tuple(_vcvars_names)}" - ) - - plat_spec = _get_vcvars_spec(get_host_platform(), plat_name) - - vc_env = _get_vc_env(plat_spec) - if not vc_env: - raise DistutilsPlatformError( - "Unable to find a compatible Visual Studio installation." - ) - self._configure(vc_env) - - self._paths = vc_env.get('path', '') - paths = self._paths.split(os.pathsep) - self.cc = _find_exe("cl.exe", paths) - self.linker = _find_exe("link.exe", paths) - self.lib = _find_exe("lib.exe", paths) - self.rc = _find_exe("rc.exe", paths) # resource compiler - self.mc = _find_exe("mc.exe", paths) # message compiler - self.mt = _find_exe("mt.exe", paths) # message compiler - - self.preprocess_options = None - # bpo-38597: Always compile with dynamic linking - # Future releases of Python 3.x will include all past - # versions of vcruntime*.dll for compatibility. - self.compile_options = ['/nologo', '/O2', '/W3', '/GL', '/DNDEBUG', '/MD'] - - self.compile_options_debug = [ - '/nologo', - '/Od', - '/MDd', - '/Zi', - '/W3', - '/D_DEBUG', - ] - - ldflags = ['/nologo', '/INCREMENTAL:NO', '/LTCG'] - - ldflags_debug = ['/nologo', '/INCREMENTAL:NO', '/LTCG', '/DEBUG:FULL'] - - self.ldflags_exe = [*ldflags, '/MANIFEST:EMBED,ID=1'] - self.ldflags_exe_debug = [*ldflags_debug, '/MANIFEST:EMBED,ID=1'] - self.ldflags_shared = [ - *ldflags, - '/DLL', - '/MANIFEST:EMBED,ID=2', - '/MANIFESTUAC:NO', - ] - self.ldflags_shared_debug = [ - *ldflags_debug, - '/DLL', - '/MANIFEST:EMBED,ID=2', - '/MANIFESTUAC:NO', - ] - self.ldflags_static = [*ldflags] - self.ldflags_static_debug = [*ldflags_debug] - - self._ldflags = { - (base.Compiler.EXECUTABLE, None): self.ldflags_exe, - (base.Compiler.EXECUTABLE, False): self.ldflags_exe, - (base.Compiler.EXECUTABLE, True): self.ldflags_exe_debug, - (base.Compiler.SHARED_OBJECT, None): self.ldflags_shared, - (base.Compiler.SHARED_OBJECT, False): self.ldflags_shared, - (base.Compiler.SHARED_OBJECT, True): self.ldflags_shared_debug, - (base.Compiler.SHARED_LIBRARY, None): self.ldflags_static, - (base.Compiler.SHARED_LIBRARY, False): self.ldflags_static, - (base.Compiler.SHARED_LIBRARY, True): self.ldflags_static_debug, - } - - self.initialized = True - - # -- Worker methods ------------------------------------------------ - - @property - def out_extensions(self) -> dict[str, str]: - return { - **super().out_extensions, - **{ - ext: self.res_extension - for ext in self._rc_extensions + self._mc_extensions - }, - } - - def compile( # noqa: C901 - self, - sources, - output_dir=None, - macros=None, - include_dirs=None, - debug=False, - extra_preargs=None, - extra_postargs=None, - depends=None, - ): - if not self.initialized: - self.initialize() - compile_info = self._setup_compile( - output_dir, macros, include_dirs, sources, depends, extra_postargs - ) - macros, objects, extra_postargs, pp_opts, build = compile_info - - compile_opts = extra_preargs or [] - compile_opts.append('/c') - if debug: - compile_opts.extend(self.compile_options_debug) - else: - compile_opts.extend(self.compile_options) - - add_cpp_opts = False - - for obj in objects: - try: - src, ext = build[obj] - except KeyError: - continue - if debug: - # pass the full pathname to MSVC in debug mode, - # this allows the debugger to find the source file - # without asking the user to browse for it - src = os.path.abspath(src) - - if ext in self._c_extensions: - input_opt = f"/Tc{src}" - elif ext in self._cpp_extensions: - input_opt = f"/Tp{src}" - add_cpp_opts = True - elif ext in self._rc_extensions: - # compile .RC to .RES file - input_opt = src - output_opt = "/fo" + obj - try: - self.spawn([self.rc] + pp_opts + [output_opt, input_opt]) - except DistutilsExecError as msg: - raise CompileError(msg) - continue - elif ext in self._mc_extensions: - # Compile .MC to .RC file to .RES file. - # * '-h dir' specifies the directory for the - # generated include file - # * '-r dir' specifies the target directory of the - # generated RC file and the binary message resource - # it includes - # - # For now (since there are no options to change this), - # we use the source-directory for the include file and - # the build directory for the RC file and message - # resources. This works at least for win32all. - h_dir = os.path.dirname(src) - rc_dir = os.path.dirname(obj) - try: - # first compile .MC to .RC and .H file - self.spawn([self.mc, '-h', h_dir, '-r', rc_dir, src]) - base, _ = os.path.splitext(os.path.basename(src)) - rc_file = os.path.join(rc_dir, base + '.rc') - # then compile .RC to .RES file - self.spawn([self.rc, "/fo" + obj, rc_file]) - - except DistutilsExecError as msg: - raise CompileError(msg) - continue - else: - # how to handle this file? - raise CompileError(f"Don't know how to compile {src} to {obj}") - - args = [self.cc] + compile_opts + pp_opts - if add_cpp_opts: - args.append('/EHsc') - args.extend((input_opt, "/Fo" + obj)) - args.extend(extra_postargs) - - try: - self.spawn(args) - except DistutilsExecError as msg: - raise CompileError(msg) - - return objects - - def create_static_lib( - self, - objects: list[str] | tuple[str, ...], - output_libname: str, - output_dir: str | None = None, - debug: bool = False, - target_lang: str | None = None, - ) -> None: - if not self.initialized: - self.initialize() - objects, output_dir = self._fix_object_args(objects, output_dir) - output_filename = self.library_filename(output_libname, output_dir=output_dir) - - if self._need_link(objects, output_filename): - lib_args = objects + ['/OUT:' + output_filename] - if debug: - pass # XXX what goes here? - try: - log.debug('Executing "%s" %s', self.lib, ' '.join(lib_args)) - self.spawn([self.lib] + lib_args) - except DistutilsExecError as msg: - raise LibError(msg) - else: - log.debug("skipping %s (up-to-date)", output_filename) - - def link( - self, - target_desc: str, - objects: list[str] | tuple[str, ...], - output_filename: str, - output_dir: str | None = None, - libraries: list[str] | tuple[str, ...] | None = None, - library_dirs: list[str] | tuple[str, ...] | None = None, - runtime_library_dirs: list[str] | tuple[str, ...] | None = None, - export_symbols: Iterable[str] | None = None, - debug: bool = False, - extra_preargs: list[str] | None = None, - extra_postargs: Iterable[str] | None = None, - build_temp: str | os.PathLike[str] | None = None, - target_lang: str | None = None, - ) -> None: - if not self.initialized: - self.initialize() - objects, output_dir = self._fix_object_args(objects, output_dir) - fixed_args = self._fix_lib_args(libraries, library_dirs, runtime_library_dirs) - libraries, library_dirs, runtime_library_dirs = fixed_args - - if runtime_library_dirs: - self.warn( - "I don't know what to do with 'runtime_library_dirs': " - + str(runtime_library_dirs) - ) - - lib_opts = gen_lib_options(self, library_dirs, runtime_library_dirs, libraries) - if output_dir is not None: - output_filename = os.path.join(output_dir, output_filename) - - if self._need_link(objects, output_filename): - ldflags = self._ldflags[target_desc, debug] - - export_opts = ["/EXPORT:" + sym for sym in (export_symbols or [])] - - ld_args = ( - ldflags + lib_opts + export_opts + objects + ['/OUT:' + output_filename] - ) - - # The MSVC linker generates .lib and .exp files, which cannot be - # suppressed by any linker switches. The .lib files may even be - # needed! Make sure they are generated in the temporary build - # directory. Since they have different names for debug and release - # builds, they can go into the same directory. - build_temp = os.path.dirname(objects[0]) - if export_symbols is not None: - (dll_name, dll_ext) = os.path.splitext( - os.path.basename(output_filename) - ) - implib_file = os.path.join(build_temp, self.library_filename(dll_name)) - ld_args.append('/IMPLIB:' + implib_file) - - if extra_preargs: - ld_args[:0] = extra_preargs - if extra_postargs: - ld_args.extend(extra_postargs) - - output_dir = os.path.dirname(os.path.abspath(output_filename)) - self.mkpath(output_dir) - try: - log.debug('Executing "%s" %s', self.linker, ' '.join(ld_args)) - self.spawn([self.linker] + ld_args) - except DistutilsExecError as msg: - raise LinkError(msg) - else: - log.debug("skipping %s (up-to-date)", output_filename) - - def spawn(self, cmd): - env = dict(os.environ, PATH=self._paths) - with self._fallback_spawn(cmd, env) as fallback: - return super().spawn(cmd, env=env) - return fallback.value - - @contextlib.contextmanager - def _fallback_spawn(self, cmd, env): - """ - Discovered in pypa/distutils#15, some tools monkeypatch the compiler, - so the 'env' kwarg causes a TypeError. Detect this condition and - restore the legacy, unsafe behavior. - """ - bag = type('Bag', (), {})() - try: - yield bag - except TypeError as exc: - if "unexpected keyword argument 'env'" not in str(exc): - raise - else: - return - warnings.warn("Fallback spawn triggered. Please update distutils monkeypatch.") - with mock.patch.dict('os.environ', env): - bag.value = super().spawn(cmd) - - # -- Miscellaneous methods ----------------------------------------- - # These are all used by the 'gen_lib_options() function, in - # ccompiler.py. - - def library_dir_option(self, dir): - return "/LIBPATH:" + dir - - def runtime_library_dir_option(self, dir): - raise DistutilsPlatformError( - "don't know how to set runtime library search path for MSVC" - ) - - def library_option(self, lib): - return self.library_filename(lib) - - def find_library_file(self, dirs, lib, debug=False): - # Prefer a debugging library if found (and requested), but deal - # with it if we don't have one. - if debug: - try_names = [lib + "_d", lib] - else: - try_names = [lib] - for dir in dirs: - for name in try_names: - libfile = os.path.join(dir, self.library_filename(name)) - if os.path.isfile(libfile): - return libfile - else: - # Oops, didn't find it in *any* of 'dirs' - return None +"""distutils._msvccompiler + +Contains MSVCCompiler, an implementation of the abstract CCompiler class +for Microsoft Visual Studio 2015. + +This module requires VS 2015 or later. +""" + +# Written by Perry Stoll +# hacked by Robin Becker and Thomas Heller to do a better job of +# finding DevStudio (through the registry) +# ported to VS 2005 and VS 2008 by Christian Heimes +# ported to VS 2015 by Steve Dower +from __future__ import annotations + +import contextlib +import os +import subprocess +import unittest.mock as mock +import warnings +from collections.abc import Iterable + +with contextlib.suppress(ImportError): + import winreg + +from itertools import count + +from ..._log import log +from ...errors import ( + DistutilsExecError, + DistutilsPlatformError, +) +from ...util import get_host_platform, get_platform +from . import base +from .base import gen_lib_options +from .errors import ( + CompileError, + LibError, + LinkError, +) + + +def _find_vc2015(): + try: + key = winreg.OpenKeyEx( + winreg.HKEY_LOCAL_MACHINE, + r"Software\Microsoft\VisualStudio\SxS\VC7", + access=winreg.KEY_READ | winreg.KEY_WOW64_32KEY, + ) + except OSError: + log.debug("Visual C++ is not registered") + return None, None + + best_version = 0 + best_dir = None + with key: + for i in count(): + try: + v, vc_dir, vt = winreg.EnumValue(key, i) + except OSError: + break + if v and vt == winreg.REG_SZ and os.path.isdir(vc_dir): + try: + version = int(float(v)) + except (ValueError, TypeError): + continue + if version >= 14 and version > best_version: + best_version, best_dir = version, vc_dir + return best_version, best_dir + + +def _find_vc2017(): + """Returns "15, path" based on the result of invoking vswhere.exe + If no install is found, returns "None, None" + + The version is returned to avoid unnecessarily changing the function + result. It may be ignored when the path is not None. + + If vswhere.exe is not available, by definition, VS 2017 is not + installed. + """ + root = os.environ.get("ProgramFiles(x86)") or os.environ.get("ProgramFiles") + if not root: + return None, None + + variant = 'arm64' if get_platform() == 'win-arm64' else 'x86.x64' + suitable_components = ( + f"Microsoft.VisualStudio.Component.VC.Tools.{variant}", + "Microsoft.VisualStudio.Workload.WDExpress", + ) + + for component in suitable_components: + # Workaround for `-requiresAny` (only available on VS 2017 > 15.6) + with contextlib.suppress( + subprocess.CalledProcessError, OSError, UnicodeDecodeError + ): + path = ( + subprocess.check_output([ + os.path.join( + root, "Microsoft Visual Studio", "Installer", "vswhere.exe" + ), + "-latest", + "-prerelease", + "-requires", + component, + "-property", + "installationPath", + "-products", + "*", + ]) + .decode(encoding="mbcs", errors="strict") + .strip() + ) + + path = os.path.join(path, "VC", "Auxiliary", "Build") + if os.path.isdir(path): + return 15, path + + return None, None # no suitable component found + + +PLAT_SPEC_TO_RUNTIME = { + 'x86': 'x86', + 'x86_amd64': 'x64', + 'x86_arm': 'arm', + 'x86_arm64': 'arm64', +} + + +def _find_vcvarsall(plat_spec): + # bpo-38597: Removed vcruntime return value + _, best_dir = _find_vc2017() + + if not best_dir: + best_version, best_dir = _find_vc2015() + + if not best_dir: + log.debug("No suitable Visual C++ version found") + return None, None + + vcvarsall = os.path.join(best_dir, "vcvarsall.bat") + if not os.path.isfile(vcvarsall): + log.debug("%s cannot be found", vcvarsall) + return None, None + + return vcvarsall, None + + +def _get_vc_env(plat_spec): + if os.getenv("DISTUTILS_USE_SDK"): + return {key.lower(): value for key, value in os.environ.items()} + + vcvarsall, _ = _find_vcvarsall(plat_spec) + if not vcvarsall: + raise DistutilsPlatformError( + 'Microsoft Visual C++ 14.0 or greater is required. ' + 'Get it with "Microsoft C++ Build Tools": ' + 'https://visualstudio.microsoft.com/visual-cpp-build-tools/' + ) + + try: + out = subprocess.check_output( + f'cmd /u /c "{vcvarsall}" {plat_spec} && set', + stderr=subprocess.STDOUT, + ).decode('utf-16le', errors='replace') + except subprocess.CalledProcessError as exc: + log.error(exc.output) + raise DistutilsPlatformError(f"Error executing {exc.cmd}") + + env = { + key.lower(): value + for key, _, value in (line.partition('=') for line in out.splitlines()) + if key and value + } + + return env + + +def _find_exe(exe, paths=None): + """Return path to an MSVC executable program. + + Tries to find the program in several places: first, one of the + MSVC program search paths from the registry; next, the directories + in the PATH environment variable. If any of those work, return an + absolute path that is known to exist. If none of them work, just + return the original program name, 'exe'. + """ + if not paths: + paths = os.getenv('path').split(os.pathsep) + for p in paths: + fn = os.path.join(os.path.abspath(p), exe) + if os.path.isfile(fn): + return fn + return exe + + +_vcvars_names = { + 'win32': 'x86', + 'win-amd64': 'amd64', + 'win-arm32': 'arm', + 'win-arm64': 'arm64', +} + + +def _get_vcvars_spec(host_platform, platform): + """ + Given a host platform and platform, determine the spec for vcvarsall. + + Uses the native MSVC host if the host platform would need expensive + emulation for x86. + + >>> _get_vcvars_spec('win-arm64', 'win32') + 'arm64_x86' + >>> _get_vcvars_spec('win-arm64', 'win-amd64') + 'arm64_amd64' + + Otherwise, always cross-compile from x86 to work with the + lighter-weight MSVC installs that do not include native 64-bit tools. + + >>> _get_vcvars_spec('win32', 'win32') + 'x86' + >>> _get_vcvars_spec('win-arm32', 'win-arm32') + 'x86_arm' + >>> _get_vcvars_spec('win-amd64', 'win-arm64') + 'x86_arm64' + """ + if host_platform != 'win-arm64': + host_platform = 'win32' + vc_hp = _vcvars_names[host_platform] + vc_plat = _vcvars_names[platform] + return vc_hp if vc_hp == vc_plat else f'{vc_hp}_{vc_plat}' + + +class Compiler(base.Compiler): + """Concrete class that implements an interface to Microsoft Visual C++, + as defined by the CCompiler abstract class.""" + + compiler_type = 'msvc' + + # Just set this so CCompiler's constructor doesn't barf. We currently + # don't use the 'set_executables()' bureaucracy provided by CCompiler, + # as it really isn't necessary for this sort of single-compiler class. + # Would be nice to have a consistent interface with UnixCCompiler, + # though, so it's worth thinking about. + executables = {} + + # Private class data (need to distinguish C from C++ source for compiler) + _c_extensions = ['.c'] + _cpp_extensions = ['.cc', '.cpp', '.cxx'] + _rc_extensions = ['.rc'] + _mc_extensions = ['.mc'] + + # Needed for the filename generation methods provided by the + # base class, CCompiler. + src_extensions = _c_extensions + _cpp_extensions + _rc_extensions + _mc_extensions + res_extension = '.res' + obj_extension = '.obj' + static_lib_extension = '.lib' + shared_lib_extension = '.dll' + static_lib_format = shared_lib_format = '%s%s' + exe_extension = '.exe' + + def __init__(self, verbose=False, dry_run=False, force=False) -> None: + super().__init__(verbose, dry_run, force) + # target platform (.plat_name is consistent with 'bdist') + self.plat_name = None + self.initialized = False + + @classmethod + def _configure(cls, vc_env): + """ + Set class-level include/lib dirs. + """ + cls.include_dirs = cls._parse_path(vc_env.get('include', '')) + cls.library_dirs = cls._parse_path(vc_env.get('lib', '')) + + @staticmethod + def _parse_path(val): + return [dir.rstrip(os.sep) for dir in val.split(os.pathsep) if dir] + + def initialize(self, plat_name: str | None = None) -> None: + # multi-init means we would need to check platform same each time... + assert not self.initialized, "don't init multiple times" + if plat_name is None: + plat_name = get_platform() + # sanity check for platforms to prevent obscure errors later. + if plat_name not in _vcvars_names: + raise DistutilsPlatformError( + f"--plat-name must be one of {tuple(_vcvars_names)}" + ) + + plat_spec = _get_vcvars_spec(get_host_platform(), plat_name) + + vc_env = _get_vc_env(plat_spec) + if not vc_env: + raise DistutilsPlatformError( + "Unable to find a compatible Visual Studio installation." + ) + self._configure(vc_env) + + self._paths = vc_env.get('path', '') + paths = self._paths.split(os.pathsep) + self.cc = _find_exe("cl.exe", paths) + self.linker = _find_exe("link.exe", paths) + self.lib = _find_exe("lib.exe", paths) + self.rc = _find_exe("rc.exe", paths) # resource compiler + self.mc = _find_exe("mc.exe", paths) # message compiler + self.mt = _find_exe("mt.exe", paths) # message compiler + + self.preprocess_options = None + # bpo-38597: Always compile with dynamic linking + # Future releases of Python 3.x will include all past + # versions of vcruntime*.dll for compatibility. + self.compile_options = ['/nologo', '/O2', '/W3', '/GL', '/DNDEBUG', '/MD'] + + self.compile_options_debug = [ + '/nologo', + '/Od', + '/MDd', + '/Zi', + '/W3', + '/D_DEBUG', + ] + + ldflags = ['/nologo', '/INCREMENTAL:NO', '/LTCG'] + + ldflags_debug = ['/nologo', '/INCREMENTAL:NO', '/LTCG', '/DEBUG:FULL'] + + self.ldflags_exe = [*ldflags, '/MANIFEST:EMBED,ID=1'] + self.ldflags_exe_debug = [*ldflags_debug, '/MANIFEST:EMBED,ID=1'] + self.ldflags_shared = [ + *ldflags, + '/DLL', + '/MANIFEST:EMBED,ID=2', + '/MANIFESTUAC:NO', + ] + self.ldflags_shared_debug = [ + *ldflags_debug, + '/DLL', + '/MANIFEST:EMBED,ID=2', + '/MANIFESTUAC:NO', + ] + self.ldflags_static = [*ldflags] + self.ldflags_static_debug = [*ldflags_debug] + + self._ldflags = { + (base.Compiler.EXECUTABLE, None): self.ldflags_exe, + (base.Compiler.EXECUTABLE, False): self.ldflags_exe, + (base.Compiler.EXECUTABLE, True): self.ldflags_exe_debug, + (base.Compiler.SHARED_OBJECT, None): self.ldflags_shared, + (base.Compiler.SHARED_OBJECT, False): self.ldflags_shared, + (base.Compiler.SHARED_OBJECT, True): self.ldflags_shared_debug, + (base.Compiler.SHARED_LIBRARY, None): self.ldflags_static, + (base.Compiler.SHARED_LIBRARY, False): self.ldflags_static, + (base.Compiler.SHARED_LIBRARY, True): self.ldflags_static_debug, + } + + self.initialized = True + + # -- Worker methods ------------------------------------------------ + + @property + def out_extensions(self) -> dict[str, str]: + return { + **super().out_extensions, + **{ + ext: self.res_extension + for ext in self._rc_extensions + self._mc_extensions + }, + } + + def compile( # noqa: C901 + self, + sources, + output_dir=None, + macros=None, + include_dirs=None, + debug=False, + extra_preargs=None, + extra_postargs=None, + depends=None, + ): + if not self.initialized: + self.initialize() + compile_info = self._setup_compile( + output_dir, macros, include_dirs, sources, depends, extra_postargs + ) + macros, objects, extra_postargs, pp_opts, build = compile_info + + compile_opts = extra_preargs or [] + compile_opts.append('/c') + if debug: + compile_opts.extend(self.compile_options_debug) + else: + compile_opts.extend(self.compile_options) + + add_cpp_opts = False + + for obj in objects: + try: + src, ext = build[obj] + except KeyError: + continue + if debug: + # pass the full pathname to MSVC in debug mode, + # this allows the debugger to find the source file + # without asking the user to browse for it + src = os.path.abspath(src) + + if ext in self._c_extensions: + input_opt = f"/Tc{src}" + elif ext in self._cpp_extensions: + input_opt = f"/Tp{src}" + add_cpp_opts = True + elif ext in self._rc_extensions: + # compile .RC to .RES file + input_opt = src + output_opt = "/fo" + obj + try: + self.spawn([self.rc] + pp_opts + [output_opt, input_opt]) + except DistutilsExecError as msg: + raise CompileError(msg) + continue + elif ext in self._mc_extensions: + # Compile .MC to .RC file to .RES file. + # * '-h dir' specifies the directory for the + # generated include file + # * '-r dir' specifies the target directory of the + # generated RC file and the binary message resource + # it includes + # + # For now (since there are no options to change this), + # we use the source-directory for the include file and + # the build directory for the RC file and message + # resources. This works at least for win32all. + h_dir = os.path.dirname(src) + rc_dir = os.path.dirname(obj) + try: + # first compile .MC to .RC and .H file + self.spawn([self.mc, '-h', h_dir, '-r', rc_dir, src]) + base, _ = os.path.splitext(os.path.basename(src)) + rc_file = os.path.join(rc_dir, base + '.rc') + # then compile .RC to .RES file + self.spawn([self.rc, "/fo" + obj, rc_file]) + + except DistutilsExecError as msg: + raise CompileError(msg) + continue + else: + # how to handle this file? + raise CompileError(f"Don't know how to compile {src} to {obj}") + + args = [self.cc] + compile_opts + pp_opts + if add_cpp_opts: + args.append('/EHsc') + args.extend((input_opt, "/Fo" + obj)) + args.extend(extra_postargs) + + try: + self.spawn(args) + except DistutilsExecError as msg: + raise CompileError(msg) + + return objects + + def create_static_lib( + self, + objects: list[str] | tuple[str, ...], + output_libname: str, + output_dir: str | None = None, + debug: bool = False, + target_lang: str | None = None, + ) -> None: + if not self.initialized: + self.initialize() + objects, output_dir = self._fix_object_args(objects, output_dir) + output_filename = self.library_filename(output_libname, output_dir=output_dir) + + if self._need_link(objects, output_filename): + lib_args = objects + ['/OUT:' + output_filename] + if debug: + pass # XXX what goes here? + try: + log.debug('Executing "%s" %s', self.lib, ' '.join(lib_args)) + self.spawn([self.lib] + lib_args) + except DistutilsExecError as msg: + raise LibError(msg) + else: + log.debug("skipping %s (up-to-date)", output_filename) + + def link( + self, + target_desc: str, + objects: list[str] | tuple[str, ...], + output_filename: str, + output_dir: str | None = None, + libraries: list[str] | tuple[str, ...] | None = None, + library_dirs: list[str] | tuple[str, ...] | None = None, + runtime_library_dirs: list[str] | tuple[str, ...] | None = None, + export_symbols: Iterable[str] | None = None, + debug: bool = False, + extra_preargs: list[str] | None = None, + extra_postargs: Iterable[str] | None = None, + build_temp: str | os.PathLike[str] | None = None, + target_lang: str | None = None, + ) -> None: + if not self.initialized: + self.initialize() + objects, output_dir = self._fix_object_args(objects, output_dir) + fixed_args = self._fix_lib_args(libraries, library_dirs, runtime_library_dirs) + libraries, library_dirs, runtime_library_dirs = fixed_args + + if runtime_library_dirs: + self.warn( + "I don't know what to do with 'runtime_library_dirs': " + + str(runtime_library_dirs) + ) + + lib_opts = gen_lib_options(self, library_dirs, runtime_library_dirs, libraries) + if output_dir is not None: + output_filename = os.path.join(output_dir, output_filename) + + if self._need_link(objects, output_filename): + ldflags = self._ldflags[target_desc, debug] + + export_opts = ["/EXPORT:" + sym for sym in (export_symbols or [])] + + ld_args = ( + ldflags + lib_opts + export_opts + objects + ['/OUT:' + output_filename] + ) + + # The MSVC linker generates .lib and .exp files, which cannot be + # suppressed by any linker switches. The .lib files may even be + # needed! Make sure they are generated in the temporary build + # directory. Since they have different names for debug and release + # builds, they can go into the same directory. + build_temp = os.path.dirname(objects[0]) + if export_symbols is not None: + (dll_name, dll_ext) = os.path.splitext( + os.path.basename(output_filename) + ) + implib_file = os.path.join(build_temp, self.library_filename(dll_name)) + ld_args.append('/IMPLIB:' + implib_file) + + if extra_preargs: + ld_args[:0] = extra_preargs + if extra_postargs: + ld_args.extend(extra_postargs) + + output_dir = os.path.dirname(os.path.abspath(output_filename)) + self.mkpath(output_dir) + try: + log.debug('Executing "%s" %s', self.linker, ' '.join(ld_args)) + self.spawn([self.linker] + ld_args) + except DistutilsExecError as msg: + raise LinkError(msg) + else: + log.debug("skipping %s (up-to-date)", output_filename) + + def spawn(self, cmd): + env = dict(os.environ, PATH=self._paths) + with self._fallback_spawn(cmd, env) as fallback: + return super().spawn(cmd, env=env) + return fallback.value + + @contextlib.contextmanager + def _fallback_spawn(self, cmd, env): + """ + Discovered in pypa/distutils#15, some tools monkeypatch the compiler, + so the 'env' kwarg causes a TypeError. Detect this condition and + restore the legacy, unsafe behavior. + """ + bag = type('Bag', (), {})() + try: + yield bag + except TypeError as exc: + if "unexpected keyword argument 'env'" not in str(exc): + raise + else: + return + warnings.warn("Fallback spawn triggered. Please update distutils monkeypatch.") + with mock.patch.dict('os.environ', env): + bag.value = super().spawn(cmd) + + # -- Miscellaneous methods ----------------------------------------- + # These are all used by the 'gen_lib_options() function, in + # ccompiler.py. + + def library_dir_option(self, dir): + return "/LIBPATH:" + dir + + def runtime_library_dir_option(self, dir): + raise DistutilsPlatformError( + "don't know how to set runtime library search path for MSVC" + ) + + def library_option(self, lib): + return self.library_filename(lib) + + def find_library_file(self, dirs, lib, debug=False): + # Prefer a debugging library if found (and requested), but deal + # with it if we don't have one. + if debug: + try_names = [lib + "_d", lib] + else: + try_names = [lib] + for dir in dirs: + for name in try_names: + libfile = os.path.join(dir, self.library_filename(name)) + if os.path.isfile(libfile): + return libfile + else: + # Oops, didn't find it in *any* of 'dirs' + return None diff --git a/setuptools/_distutils/compilers/C/py.typed b/setuptools/_distutils/compilers/C/py.typed new file mode 100644 index 0000000000..8f09c62b9b --- /dev/null +++ b/setuptools/_distutils/compilers/C/py.typed @@ -0,0 +1 @@ +# Ensure that checkers who see this as a separate namespace package still understand it's typed. diff --git a/setuptools/_distutils/compilers/C/tests/test_base.py b/setuptools/_distutils/compilers/C/tests/test_base.py index a762e2b649..32a3172973 100644 --- a/setuptools/_distutils/compilers/C/tests/test_base.py +++ b/setuptools/_distutils/compilers/C/tests/test_base.py @@ -1,83 +1,83 @@ -import platform -import sysconfig -import textwrap - -import pytest - -from .. import base - -pytestmark = pytest.mark.usefixtures('suppress_path_mangle') - - -@pytest.fixture -def c_file(tmp_path): - c_file = tmp_path / 'foo.c' - gen_headers = ('Python.h',) - is_windows = platform.system() == "Windows" - plat_headers = ('windows.h',) * is_windows - all_headers = gen_headers + plat_headers - headers = '\n'.join(f'#include <{header}>\n' for header in all_headers) - payload = ( - textwrap.dedent( - """ - #headers - void PyInit_foo(void) {} - """ - ) - .lstrip() - .replace('#headers', headers) - ) - c_file.write_text(payload, encoding='utf-8') - return c_file - - -def test_set_include_dirs(c_file): - """ - Extensions should build even if set_include_dirs is invoked. - In particular, compiler-specific paths should not be overridden. - """ - compiler = base.new_compiler() - python = sysconfig.get_paths()['include'] - compiler.set_include_dirs([python]) - compiler.compile([c_file]) - - # do it again, setting include dirs after any initialization - compiler.set_include_dirs([python]) - compiler.compile([c_file]) - - -def test_has_function_prototype(): - # Issue https://github.com/pypa/setuptools/issues/3648 - # Test prototype-generating behavior. - - compiler = base.new_compiler() - - # Every C implementation should have these. - assert compiler.has_function('abort') - assert compiler.has_function('exit') - with pytest.deprecated_call(match='includes is deprecated'): - # abort() is a valid expression with the prototype. - assert compiler.has_function('abort', includes=['stdlib.h']) - with pytest.deprecated_call(match='includes is deprecated'): - # But exit() is not valid with the actual prototype in scope. - assert not compiler.has_function('exit', includes=['stdlib.h']) - # And setuptools_does_not_exist is not declared or defined at all. - assert not compiler.has_function('setuptools_does_not_exist') - with pytest.deprecated_call(match='includes is deprecated'): - assert not compiler.has_function( - 'setuptools_does_not_exist', includes=['stdio.h'] - ) - - -def test_include_dirs_after_multiple_compile_calls(c_file): - """ - Calling compile multiple times should not change the include dirs - (regression test for setuptools issue #3591). - """ - compiler = base.new_compiler() - python = sysconfig.get_paths()['include'] - compiler.set_include_dirs([python]) - compiler.compile([c_file]) - assert compiler.include_dirs == [python] - compiler.compile([c_file]) - assert compiler.include_dirs == [python] +import platform +import sysconfig +import textwrap + +import pytest + +from .. import base + +pytestmark = pytest.mark.usefixtures('suppress_path_mangle') + + +@pytest.fixture +def c_file(tmp_path): + c_file = tmp_path / 'foo.c' + gen_headers = ('Python.h',) + is_windows = platform.system() == "Windows" + plat_headers = ('windows.h',) * is_windows + all_headers = gen_headers + plat_headers + headers = '\n'.join(f'#include <{header}>\n' for header in all_headers) + payload = ( + textwrap.dedent( + """ + #headers + void PyInit_foo(void) {} + """ + ) + .lstrip() + .replace('#headers', headers) + ) + c_file.write_text(payload, encoding='utf-8') + return c_file + + +def test_set_include_dirs(c_file): + """ + Extensions should build even if set_include_dirs is invoked. + In particular, compiler-specific paths should not be overridden. + """ + compiler = base.new_compiler() + python = sysconfig.get_paths()['include'] + compiler.set_include_dirs([python]) + compiler.compile([c_file]) + + # do it again, setting include dirs after any initialization + compiler.set_include_dirs([python]) + compiler.compile([c_file]) + + +def test_has_function_prototype(): + # Issue https://github.com/pypa/setuptools/issues/3648 + # Test prototype-generating behavior. + + compiler = base.new_compiler() + + # Every C implementation should have these. + assert compiler.has_function('abort') + assert compiler.has_function('exit') + with pytest.deprecated_call(match='includes is deprecated'): + # abort() is a valid expression with the prototype. + assert compiler.has_function('abort', includes=['stdlib.h']) + with pytest.deprecated_call(match='includes is deprecated'): + # But exit() is not valid with the actual prototype in scope. + assert not compiler.has_function('exit', includes=['stdlib.h']) + # And setuptools_does_not_exist is not declared or defined at all. + assert not compiler.has_function('setuptools_does_not_exist') + with pytest.deprecated_call(match='includes is deprecated'): + assert not compiler.has_function( + 'setuptools_does_not_exist', includes=['stdio.h'] + ) + + +def test_include_dirs_after_multiple_compile_calls(c_file): + """ + Calling compile multiple times should not change the include dirs + (regression test for setuptools issue #3591). + """ + compiler = base.new_compiler() + python = sysconfig.get_paths()['include'] + compiler.set_include_dirs([python]) + compiler.compile([c_file]) + assert compiler.include_dirs == [python] + compiler.compile([c_file]) + assert compiler.include_dirs == [python] diff --git a/setuptools/_distutils/compilers/C/unix.py b/setuptools/_distutils/compilers/C/unix.py index 1231b32d20..85810d7507 100644 --- a/setuptools/_distutils/compilers/C/unix.py +++ b/setuptools/_distutils/compilers/C/unix.py @@ -322,7 +322,7 @@ def _is_gcc(self): compiler = os.path.basename(shlex.split(cc_var)[0]) return "gcc" in compiler or "g++" in compiler - def runtime_library_dir_option(self, dir: str) -> str | list[str]: # type: ignore[override] # Fixed in pypa/distutils#339 + def runtime_library_dir_option(self, dir: str) -> str | list[str]: # XXX Hackish, at the very least. See Python bug #445902: # https://bugs.python.org/issue445902 # Linkers on different platforms need different options to diff --git a/setuptools/_distutils/dist.py b/setuptools/_distutils/dist.py index 37b788df92..b9552a8b24 100644 --- a/setuptools/_distutils/dist.py +++ b/setuptools/_distutils/dist.py @@ -45,6 +45,7 @@ # type-only import because of mutual dependence between these modules from .cmd import Command + from .extension import Extension _CommandT = TypeVar("_CommandT", bound="Command") _OptionsList: TypeAlias = list[ @@ -220,18 +221,18 @@ def __init__(self, attrs: MutableMapping[str, Any] | None = None) -> None: # no # These options are really the business of various commands, rather # than of the Distribution itself. We provide aliases for them in # Distribution as a convenience to the developer. - self.packages = None + self.packages: list[str] | None = None self.package_data: dict[str, list[str]] = {} - self.package_dir = None - self.py_modules = None + self.package_dir: dict[str, str] | None = None + self.py_modules: list[str] | None = None self.libraries = None self.headers = None - self.ext_modules = None + self.ext_modules: list[Extension] | None = None self.ext_package = None self.include_dirs = None self.extra_path = None self.scripts = None - self.data_files = None + self.data_files: list[str | tuple] | None = None self.password = '' # And now initialize bookkeeping stuff that can't be supplied by @@ -1024,25 +1025,25 @@ def run_command(self, command: str) -> None: # -- Distribution query methods ------------------------------------ def has_pure_modules(self) -> bool: - return len(self.packages or self.py_modules or []) > 0 + return bool(self.packages or self.py_modules) def has_ext_modules(self) -> bool: - return self.ext_modules and len(self.ext_modules) > 0 + return bool(self.ext_modules) def has_c_libraries(self) -> bool: - return self.libraries and len(self.libraries) > 0 + return bool(self.libraries) def has_modules(self) -> bool: return self.has_pure_modules() or self.has_ext_modules() def has_headers(self) -> bool: - return self.headers and len(self.headers) > 0 + return bool(self.headers) def has_scripts(self) -> bool: - return self.scripts and len(self.scripts) > 0 + return bool(self.scripts) def has_data_files(self) -> bool: - return self.data_files and len(self.data_files) > 0 + return bool(self.data_files) def is_pure(self) -> bool: return ( diff --git a/setuptools/_distutils/fancy_getopt.py b/setuptools/_distutils/fancy_getopt.py index 1a1d3a05da..7d07911807 100644 --- a/setuptools/_distutils/fancy_getopt.py +++ b/setuptools/_distutils/fancy_getopt.py @@ -105,7 +105,7 @@ def add_option(self, long_option, short_option=None, help_string=None): self.option_table.append(option) self.option_index[long_option] = option - def has_option(self, long_option): + def has_option(self, long_option: str) -> bool: """Return true if the option table for this parser has an option with long name 'long_option'.""" return long_option in self.option_index diff --git a/setuptools/_distutils/py.typed b/setuptools/_distutils/py.typed new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/setuptools/_distutils/py.typed @@ -0,0 +1 @@ + diff --git a/setuptools/_distutils/spawn.py b/setuptools/_distutils/spawn.py index 973668f268..91519402bc 100644 --- a/setuptools/_distutils/spawn.py +++ b/setuptools/_distutils/spawn.py @@ -1,134 +1,134 @@ -"""distutils.spawn - -Provides the 'spawn()' function, a front-end to various platform- -specific functions for launching another program in a sub-process. -""" - -from __future__ import annotations - -import os -import platform -import shutil -import subprocess -import sys -import warnings -from collections.abc import Mapping, MutableSequence -from typing import TYPE_CHECKING, TypeVar, overload - -from ._log import log -from .debug import DEBUG -from .errors import DistutilsExecError - -if TYPE_CHECKING: - from subprocess import _ENV - - -_MappingT = TypeVar("_MappingT", bound=Mapping) - - -def _debug(cmd): - """ - Render a subprocess command differently depending on DEBUG. - """ - return cmd if DEBUG else cmd[0] - - -def _inject_macos_ver(env: _MappingT | None) -> _MappingT | dict[str, str | int] | None: - if platform.system() != 'Darwin': - return env - - from .util import MACOSX_VERSION_VAR, get_macosx_target_ver - - target_ver = get_macosx_target_ver() - update = {MACOSX_VERSION_VAR: target_ver} if target_ver else {} - return {**_resolve(env), **update} - - -@overload -def _resolve(env: None) -> os._Environ[str]: ... -@overload -def _resolve(env: _MappingT) -> _MappingT: ... -def _resolve(env: _MappingT | None) -> _MappingT | os._Environ[str]: - return os.environ if env is None else env - - -def spawn( - cmd: MutableSequence[bytes | str | os.PathLike[str]], - search_path: bool = True, - verbose: bool = False, - dry_run: bool = False, - env: _ENV | None = None, -) -> None: - """Run another program, specified as a command list 'cmd', in a new process. - - 'cmd' is just the argument list for the new process, ie. - cmd[0] is the program to run and cmd[1:] are the rest of its arguments. - There is no way to run a program with a name different from that of its - executable. - - If 'search_path' is true (the default), the system's executable - search path will be used to find the program; otherwise, cmd[0] - must be the exact path to the executable. If 'dry_run' is true, - the command will not actually be run. - - Raise DistutilsExecError if running the program fails in any way; just - return on success. - """ - log.info(subprocess.list2cmdline(cmd)) - if dry_run: - return - - if search_path: - executable = shutil.which(cmd[0]) - if executable is not None: - cmd[0] = executable - - try: - subprocess.check_call(cmd, env=_inject_macos_ver(env)) - except OSError as exc: - raise DistutilsExecError( - f"command {_debug(cmd)!r} failed: {exc.args[-1]}" - ) from exc - except subprocess.CalledProcessError as err: - raise DistutilsExecError( - f"command {_debug(cmd)!r} failed with exit code {err.returncode}" - ) from err - - -def find_executable(executable: str, path: str | None = None) -> str | None: - """Tries to find 'executable' in the directories listed in 'path'. - - A string listing directories separated by 'os.pathsep'; defaults to - os.environ['PATH']. Returns the complete filename or None if not found. - """ - warnings.warn( - 'Use shutil.which instead of find_executable', DeprecationWarning, stacklevel=2 - ) - _, ext = os.path.splitext(executable) - if (sys.platform == 'win32') and (ext != '.exe'): - executable = executable + '.exe' - - if os.path.isfile(executable): - return executable - - if path is None: - path = os.environ.get('PATH', None) - # bpo-35755: Don't fall through if PATH is the empty string - if path is None: - try: - path = os.confstr("CS_PATH") - except (AttributeError, ValueError): - # os.confstr() or CS_PATH is not available - path = os.defpath - - # PATH='' doesn't match, whereas PATH=':' looks in the current directory - if not path: - return None - - paths = path.split(os.pathsep) - for p in paths: - f = os.path.join(p, executable) - if os.path.isfile(f): - # the file exists, we have a shot at spawn working - return f - return None +"""distutils.spawn + +Provides the 'spawn()' function, a front-end to various platform- +specific functions for launching another program in a sub-process. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +import warnings +from collections.abc import Mapping, MutableSequence +from typing import TYPE_CHECKING, TypeVar, overload + +from ._log import log +from .debug import DEBUG +from .errors import DistutilsExecError + +if TYPE_CHECKING: + from subprocess import _ENV + + +_MappingT = TypeVar("_MappingT", bound=Mapping) + + +def _debug(cmd): + """ + Render a subprocess command differently depending on DEBUG. + """ + return cmd if DEBUG else cmd[0] + + +def _inject_macos_ver(env: _MappingT | None) -> _MappingT | dict[str, str | int] | None: + if platform.system() != 'Darwin': + return env + + from .util import MACOSX_VERSION_VAR, get_macosx_target_ver + + target_ver = get_macosx_target_ver() + update = {MACOSX_VERSION_VAR: target_ver} if target_ver else {} + return {**_resolve(env), **update} + + +@overload +def _resolve(env: None) -> os._Environ[str]: ... +@overload +def _resolve(env: _MappingT) -> _MappingT: ... +def _resolve(env: _MappingT | None) -> _MappingT | os._Environ[str]: + return os.environ if env is None else env + + +def spawn( + cmd: MutableSequence[bytes | str | os.PathLike[str]], + search_path: bool = True, + verbose: bool = False, + dry_run: bool = False, + env: _ENV | None = None, +) -> None: + """Run another program, specified as a command list 'cmd', in a new process. + + 'cmd' is just the argument list for the new process, ie. + cmd[0] is the program to run and cmd[1:] are the rest of its arguments. + There is no way to run a program with a name different from that of its + executable. + + If 'search_path' is true (the default), the system's executable + search path will be used to find the program; otherwise, cmd[0] + must be the exact path to the executable. If 'dry_run' is true, + the command will not actually be run. + + Raise DistutilsExecError if running the program fails in any way; just + return on success. + """ + log.info(subprocess.list2cmdline(cmd)) + if dry_run: + return + + if search_path: + executable = shutil.which(cmd[0]) + if executable is not None: + cmd[0] = executable + + try: + subprocess.check_call(cmd, env=_inject_macos_ver(env)) + except OSError as exc: + raise DistutilsExecError( + f"command {_debug(cmd)!r} failed: {exc.args[-1]}" + ) from exc + except subprocess.CalledProcessError as err: + raise DistutilsExecError( + f"command {_debug(cmd)!r} failed with exit code {err.returncode}" + ) from err + + +def find_executable(executable: str, path: str | None = None) -> str | None: + """Tries to find 'executable' in the directories listed in 'path'. + + A string listing directories separated by 'os.pathsep'; defaults to + os.environ['PATH']. Returns the complete filename or None if not found. + """ + warnings.warn( + 'Use shutil.which instead of find_executable', DeprecationWarning, stacklevel=2 + ) + _, ext = os.path.splitext(executable) + if (sys.platform == 'win32') and (ext != '.exe'): + executable = executable + '.exe' + + if os.path.isfile(executable): + return executable + + if path is None: + path = os.environ.get('PATH', None) + # bpo-35755: Don't fall through if PATH is the empty string + if path is None: + try: + path = os.confstr("CS_PATH") + except (AttributeError, ValueError): + # os.confstr() or CS_PATH is not available + path = os.defpath + + # PATH='' doesn't match, whereas PATH=':' looks in the current directory + if not path: + return None + + paths = path.split(os.pathsep) + for p in paths: + f = os.path.join(p, executable) + if os.path.isfile(f): + # the file exists, we have a shot at spawn working + return f + return None diff --git a/setuptools/_distutils/tests/test_check.py b/setuptools/_distutils/tests/test_check.py index b672b1f972..c71ba4ff7b 100644 --- a/setuptools/_distutils/tests/test_check.py +++ b/setuptools/_distutils/tests/test_check.py @@ -1,20 +1,43 @@ """Tests for distutils.command.check.""" +import distutils.command.check as _check +import importlib import os +import sys import textwrap -from distutils.command.check import check from distutils.errors import DistutilsSetupError from distutils.tests import support import pytest -try: - import pygments -except ImportError: - pygments = None +HERE = os.path.dirname(__file__) -HERE = os.path.dirname(__file__) +@pytest.fixture +def hide_pygments(monkeypatch, request): + """ + Clear docutils and hide the presence of pygments. + """ + clear_docutils(monkeypatch) + monkeypatch.setitem(sys.modules, 'pygments', None) + reload_check() + # restore 'check' to its normal state after monkeypatch is undone + request.addfinalizer(reload_check) + + +def clear_docutils(monkeypatch): + docutils_names = [ + name for name in sys.modules if name.partition('.')[0] == 'docutils' + ] + for name in docutils_names: + monkeypatch.delitem(sys.modules, name) + + +def reload_check(): + """ + Reload the 'check' command module to reflect the import state. + """ + importlib.reload(_check) @support.combine_markers @@ -26,7 +49,7 @@ def _run(self, metadata=None, cwd=None, **options): old_dir = os.getcwd() os.chdir(cwd) pkg_info, dist = self.create_dist(**metadata) - cmd = check(dist) + cmd = _check.check(dist) cmd.initialize_options() for name, value in options.items(): setattr(cmd, name, value) @@ -103,9 +126,8 @@ def test_check_author_maintainer(self): assert cmd._warnings == 0 def test_check_document(self): - pytest.importorskip('docutils') pkg_info, dist = self.create_dist() - cmd = check(dist) + cmd = _check.check(dist) # let's see if it detects broken rest broken_rest = 'title\n===\n\ntest' @@ -118,11 +140,10 @@ def test_check_document(self): assert len(msgs) == 0 def test_check_restructuredtext(self): - pytest.importorskip('docutils') # let's see if it detects broken rest in long_description broken_rest = 'title\n===\n\ntest' pkg_info, dist = self.create_dist(long_description=broken_rest) - cmd = check(dist) + cmd = _check.check(dist) cmd.check_restructuredtext() assert cmd._warnings == 1 @@ -148,46 +169,35 @@ def test_check_restructuredtext(self): cmd = self._run(metadata, cwd=HERE, strict=True, restructuredtext=True) assert cmd._warnings == 0 - def test_check_restructuredtext_with_syntax_highlight(self): - pytest.importorskip('docutils') - # Don't fail if there is a `code` or `code-block` directive - - example_rst_docs = [ - textwrap.dedent( - """\ + code_examples = [ + textwrap.dedent( + f""" Here's some code: - .. code:: python + .. {directive}:: python def foo(): pass """ - ), - textwrap.dedent( - """\ - Here's some code: + ).lstrip() + for directive in ['code', 'code-block'] + ] - .. code-block:: python + def check_rst_data(self, descr): + pkg_info, dist = self.create_dist(long_description=descr) + cmd = _check.check(dist) + cmd.check_restructuredtext() + return cmd._check_rst_data(descr) - def foo(): - pass - """ - ), - ] - - for rest_with_code in example_rst_docs: - pkg_info, dist = self.create_dist(long_description=rest_with_code) - cmd = check(dist) - cmd.check_restructuredtext() - msgs = cmd._check_rst_data(rest_with_code) - if pygments is not None: - assert len(msgs) == 0 - else: - assert len(msgs) == 1 - assert ( - str(msgs[0][1]) - == 'Cannot analyze code. Pygments package not found.' - ) + @pytest.mark.parametrize('descr', code_examples) + def test_check_rst_with_syntax_highlight_pygments(self, descr): + assert self.check_rst_data(descr) == [] + + @pytest.mark.parametrize('descr', code_examples) + def test_check_rst_with_syntax_highlight_no_pygments(self, descr, hide_pygments): + (msg,) = self.check_rst_data(descr) + _, exc, _, _ = msg + assert str(exc) == 'Cannot analyze code. Pygments package not found.' def test_check_all(self): with pytest.raises(DistutilsSetupError): diff --git a/setuptools/_distutils/tests/test_filelist.py b/setuptools/_distutils/tests/test_filelist.py index 130e6fb53b..ff01395b3e 100644 --- a/setuptools/_distutils/tests/test_filelist.py +++ b/setuptools/_distutils/tests/test_filelist.py @@ -1,336 +1,336 @@ -"""Tests for distutils.filelist.""" - -import logging -import os -import re -from distutils import debug, filelist -from distutils.errors import DistutilsTemplateError -from distutils.filelist import FileList, glob_to_re, translate_pattern - -import jaraco.path -import pytest - -from .compat import py39 as os_helper - -MANIFEST_IN = """\ -include ok -include xo -exclude xo -include foo.tmp -include buildout.cfg -global-include *.x -global-include *.txt -global-exclude *.tmp -recursive-include f *.oo -recursive-exclude global *.x -graft dir -prune dir3 -""" - - -def make_local_path(s): - """Converts '/' in a string to os.sep""" - return s.replace('/', os.sep) - - -class TestFileList: - def assertNoWarnings(self, caplog): - warnings = [rec for rec in caplog.records if rec.levelno == logging.WARNING] - assert not warnings - caplog.clear() - - def assertWarnings(self, caplog): - warnings = [rec for rec in caplog.records if rec.levelno == logging.WARNING] - assert warnings - caplog.clear() - - def test_glob_to_re(self): - sep = os.sep - if os.sep == '\\': - sep = re.escape(os.sep) - - for glob, regex in ( - # simple cases - ('foo*', r'(?s:foo[^%(sep)s]*)\Z'), - ('foo?', r'(?s:foo[^%(sep)s])\Z'), - ('foo??', r'(?s:foo[^%(sep)s][^%(sep)s])\Z'), - # special cases - (r'foo\\*', r'(?s:foo\\\\[^%(sep)s]*)\Z'), - (r'foo\\\*', r'(?s:foo\\\\\\[^%(sep)s]*)\Z'), - ('foo????', r'(?s:foo[^%(sep)s][^%(sep)s][^%(sep)s][^%(sep)s])\Z'), - (r'foo\\??', r'(?s:foo\\\\[^%(sep)s][^%(sep)s])\Z'), - ): - regex = regex % {'sep': sep} - assert glob_to_re(glob) == regex - - def test_process_template_line(self): - # testing all MANIFEST.in template patterns - file_list = FileList() - mlp = make_local_path - - # simulated file list - file_list.allfiles = [ - 'foo.tmp', - 'ok', - 'xo', - 'four.txt', - 'buildout.cfg', - # filelist does not filter out VCS directories, - # it's sdist that does - mlp('.hg/last-message.txt'), - mlp('global/one.txt'), - mlp('global/two.txt'), - mlp('global/files.x'), - mlp('global/here.tmp'), - mlp('f/o/f.oo'), - mlp('dir/graft-one'), - mlp('dir/dir2/graft2'), - mlp('dir3/ok'), - mlp('dir3/sub/ok.txt'), - ] - - for line in MANIFEST_IN.split('\n'): - if line.strip() == '': - continue - file_list.process_template_line(line) - - wanted = [ - 'ok', - 'buildout.cfg', - 'four.txt', - mlp('.hg/last-message.txt'), - mlp('global/one.txt'), - mlp('global/two.txt'), - mlp('f/o/f.oo'), - mlp('dir/graft-one'), - mlp('dir/dir2/graft2'), - ] - - assert file_list.files == wanted - - def test_debug_print(self, capsys, monkeypatch): - file_list = FileList() - file_list.debug_print('xxx') - assert capsys.readouterr().out == '' - - monkeypatch.setattr(debug, 'DEBUG', True) - file_list.debug_print('xxx') - assert capsys.readouterr().out == 'xxx\n' - - def test_set_allfiles(self): - file_list = FileList() - files = ['a', 'b', 'c'] - file_list.set_allfiles(files) - assert file_list.allfiles == files - - def test_remove_duplicates(self): - file_list = FileList() - file_list.files = ['a', 'b', 'a', 'g', 'c', 'g'] - # files must be sorted beforehand (sdist does it) - file_list.sort() - file_list.remove_duplicates() - assert file_list.files == ['a', 'b', 'c', 'g'] - - def test_translate_pattern(self): - # not regex - assert hasattr(translate_pattern('a', anchor=True, is_regex=False), 'search') - - # is a regex - regex = re.compile('a') - assert translate_pattern(regex, anchor=True, is_regex=True) == regex - - # plain string flagged as regex - assert hasattr(translate_pattern('a', anchor=True, is_regex=True), 'search') - - # glob support - assert translate_pattern('*.py', anchor=True, is_regex=False).search( - 'filelist.py' - ) - - def test_exclude_pattern(self): - # return False if no match - file_list = FileList() - assert not file_list.exclude_pattern('*.py') - - # return True if files match - file_list = FileList() - file_list.files = ['a.py', 'b.py'] - assert file_list.exclude_pattern('*.py') - - # test excludes - file_list = FileList() - file_list.files = ['a.py', 'a.txt'] - file_list.exclude_pattern('*.py') - assert file_list.files == ['a.txt'] - - def test_include_pattern(self): - # return False if no match - file_list = FileList() - file_list.set_allfiles([]) - assert not file_list.include_pattern('*.py') - - # return True if files match - file_list = FileList() - file_list.set_allfiles(['a.py', 'b.txt']) - assert file_list.include_pattern('*.py') - - # test * matches all files - file_list = FileList() - assert file_list.allfiles is None - file_list.set_allfiles(['a.py', 'b.txt']) - file_list.include_pattern('*') - assert file_list.allfiles == ['a.py', 'b.txt'] - - def test_process_template(self, caplog): - mlp = make_local_path - # invalid lines - file_list = FileList() - for action in ( - 'include', - 'exclude', - 'global-include', - 'global-exclude', - 'recursive-include', - 'recursive-exclude', - 'graft', - 'prune', - 'blarg', - ): - with pytest.raises(DistutilsTemplateError): - file_list.process_template_line(action) - - # include - file_list = FileList() - file_list.set_allfiles(['a.py', 'b.txt', mlp('d/c.py')]) - - file_list.process_template_line('include *.py') - assert file_list.files == ['a.py'] - self.assertNoWarnings(caplog) - - file_list.process_template_line('include *.rb') - assert file_list.files == ['a.py'] - self.assertWarnings(caplog) - - # exclude - file_list = FileList() - file_list.files = ['a.py', 'b.txt', mlp('d/c.py')] - - file_list.process_template_line('exclude *.py') - assert file_list.files == ['b.txt', mlp('d/c.py')] - self.assertNoWarnings(caplog) - - file_list.process_template_line('exclude *.rb') - assert file_list.files == ['b.txt', mlp('d/c.py')] - self.assertWarnings(caplog) - - # global-include - file_list = FileList() - file_list.set_allfiles(['a.py', 'b.txt', mlp('d/c.py')]) - - file_list.process_template_line('global-include *.py') - assert file_list.files == ['a.py', mlp('d/c.py')] - self.assertNoWarnings(caplog) - - file_list.process_template_line('global-include *.rb') - assert file_list.files == ['a.py', mlp('d/c.py')] - self.assertWarnings(caplog) - - # global-exclude - file_list = FileList() - file_list.files = ['a.py', 'b.txt', mlp('d/c.py')] - - file_list.process_template_line('global-exclude *.py') - assert file_list.files == ['b.txt'] - self.assertNoWarnings(caplog) - - file_list.process_template_line('global-exclude *.rb') - assert file_list.files == ['b.txt'] - self.assertWarnings(caplog) - - # recursive-include - file_list = FileList() - file_list.set_allfiles(['a.py', mlp('d/b.py'), mlp('d/c.txt'), mlp('d/d/e.py')]) - - file_list.process_template_line('recursive-include d *.py') - assert file_list.files == [mlp('d/b.py'), mlp('d/d/e.py')] - self.assertNoWarnings(caplog) - - file_list.process_template_line('recursive-include e *.py') - assert file_list.files == [mlp('d/b.py'), mlp('d/d/e.py')] - self.assertWarnings(caplog) - - # recursive-exclude - file_list = FileList() - file_list.files = ['a.py', mlp('d/b.py'), mlp('d/c.txt'), mlp('d/d/e.py')] - - file_list.process_template_line('recursive-exclude d *.py') - assert file_list.files == ['a.py', mlp('d/c.txt')] - self.assertNoWarnings(caplog) - - file_list.process_template_line('recursive-exclude e *.py') - assert file_list.files == ['a.py', mlp('d/c.txt')] - self.assertWarnings(caplog) - - # graft - file_list = FileList() - file_list.set_allfiles(['a.py', mlp('d/b.py'), mlp('d/d/e.py'), mlp('f/f.py')]) - - file_list.process_template_line('graft d') - assert file_list.files == [mlp('d/b.py'), mlp('d/d/e.py')] - self.assertNoWarnings(caplog) - - file_list.process_template_line('graft e') - assert file_list.files == [mlp('d/b.py'), mlp('d/d/e.py')] - self.assertWarnings(caplog) - - # prune - file_list = FileList() - file_list.files = ['a.py', mlp('d/b.py'), mlp('d/d/e.py'), mlp('f/f.py')] - - file_list.process_template_line('prune d') - assert file_list.files == ['a.py', mlp('f/f.py')] - self.assertNoWarnings(caplog) - - file_list.process_template_line('prune e') - assert file_list.files == ['a.py', mlp('f/f.py')] - self.assertWarnings(caplog) - - -class TestFindAll: - @os_helper.skip_unless_symlink - def test_missing_symlink(self, temp_cwd): - os.symlink('foo', 'bar') - assert filelist.findall() == [] - - def test_basic_discovery(self, temp_cwd): - """ - When findall is called with no parameters or with - '.' as the parameter, the dot should be omitted from - the results. - """ - jaraco.path.build({'foo': {'file1.txt': ''}, 'bar': {'file2.txt': ''}}) - file1 = os.path.join('foo', 'file1.txt') - file2 = os.path.join('bar', 'file2.txt') - expected = [file2, file1] - assert sorted(filelist.findall()) == expected - - def test_non_local_discovery(self, tmp_path): - """ - When findall is called with another path, the full - path name should be returned. - """ - jaraco.path.build({'file1.txt': ''}, tmp_path) - expected = [str(tmp_path / 'file1.txt')] - assert filelist.findall(tmp_path) == expected - - @os_helper.skip_unless_symlink - def test_symlink_loop(self, tmp_path): - jaraco.path.build( - { - 'link-to-parent': jaraco.path.Symlink('.'), - 'somefile': '', - }, - tmp_path, - ) - files = filelist.findall(tmp_path) - assert len(files) == 1 +"""Tests for distutils.filelist.""" + +import logging +import os +import re +from distutils import debug, filelist +from distutils.errors import DistutilsTemplateError +from distutils.filelist import FileList, glob_to_re, translate_pattern + +import jaraco.path +import pytest + +from .compat import py39 as os_helper + +MANIFEST_IN = """\ +include ok +include xo +exclude xo +include foo.tmp +include buildout.cfg +global-include *.x +global-include *.txt +global-exclude *.tmp +recursive-include f *.oo +recursive-exclude global *.x +graft dir +prune dir3 +""" + + +def make_local_path(s): + """Converts '/' in a string to os.sep""" + return s.replace('/', os.sep) + + +class TestFileList: + def assertNoWarnings(self, caplog): + warnings = [rec for rec in caplog.records if rec.levelno == logging.WARNING] + assert not warnings + caplog.clear() + + def assertWarnings(self, caplog): + warnings = [rec for rec in caplog.records if rec.levelno == logging.WARNING] + assert warnings + caplog.clear() + + def test_glob_to_re(self): + sep = os.sep + if os.sep == '\\': + sep = re.escape(os.sep) + + for glob, regex in ( + # simple cases + ('foo*', r'(?s:foo[^%(sep)s]*)\Z'), + ('foo?', r'(?s:foo[^%(sep)s])\Z'), + ('foo??', r'(?s:foo[^%(sep)s][^%(sep)s])\Z'), + # special cases + (r'foo\\*', r'(?s:foo\\\\[^%(sep)s]*)\Z'), + (r'foo\\\*', r'(?s:foo\\\\\\[^%(sep)s]*)\Z'), + ('foo????', r'(?s:foo[^%(sep)s][^%(sep)s][^%(sep)s][^%(sep)s])\Z'), + (r'foo\\??', r'(?s:foo\\\\[^%(sep)s][^%(sep)s])\Z'), + ): + regex = regex % {'sep': sep} + assert glob_to_re(glob) == regex + + def test_process_template_line(self): + # testing all MANIFEST.in template patterns + file_list = FileList() + mlp = make_local_path + + # simulated file list + file_list.allfiles = [ + 'foo.tmp', + 'ok', + 'xo', + 'four.txt', + 'buildout.cfg', + # filelist does not filter out VCS directories, + # it's sdist that does + mlp('.hg/last-message.txt'), + mlp('global/one.txt'), + mlp('global/two.txt'), + mlp('global/files.x'), + mlp('global/here.tmp'), + mlp('f/o/f.oo'), + mlp('dir/graft-one'), + mlp('dir/dir2/graft2'), + mlp('dir3/ok'), + mlp('dir3/sub/ok.txt'), + ] + + for line in MANIFEST_IN.split('\n'): + if line.strip() == '': + continue + file_list.process_template_line(line) + + wanted = [ + 'ok', + 'buildout.cfg', + 'four.txt', + mlp('.hg/last-message.txt'), + mlp('global/one.txt'), + mlp('global/two.txt'), + mlp('f/o/f.oo'), + mlp('dir/graft-one'), + mlp('dir/dir2/graft2'), + ] + + assert file_list.files == wanted + + def test_debug_print(self, capsys, monkeypatch): + file_list = FileList() + file_list.debug_print('xxx') + assert capsys.readouterr().out == '' + + monkeypatch.setattr(debug, 'DEBUG', True) + file_list.debug_print('xxx') + assert capsys.readouterr().out == 'xxx\n' + + def test_set_allfiles(self): + file_list = FileList() + files = ['a', 'b', 'c'] + file_list.set_allfiles(files) + assert file_list.allfiles == files + + def test_remove_duplicates(self): + file_list = FileList() + file_list.files = ['a', 'b', 'a', 'g', 'c', 'g'] + # files must be sorted beforehand (sdist does it) + file_list.sort() + file_list.remove_duplicates() + assert file_list.files == ['a', 'b', 'c', 'g'] + + def test_translate_pattern(self): + # not regex + assert hasattr(translate_pattern('a', anchor=True, is_regex=False), 'search') + + # is a regex + regex = re.compile('a') + assert translate_pattern(regex, anchor=True, is_regex=True) == regex + + # plain string flagged as regex + assert hasattr(translate_pattern('a', anchor=True, is_regex=True), 'search') + + # glob support + assert translate_pattern('*.py', anchor=True, is_regex=False).search( + 'filelist.py' + ) + + def test_exclude_pattern(self): + # return False if no match + file_list = FileList() + assert not file_list.exclude_pattern('*.py') + + # return True if files match + file_list = FileList() + file_list.files = ['a.py', 'b.py'] + assert file_list.exclude_pattern('*.py') + + # test excludes + file_list = FileList() + file_list.files = ['a.py', 'a.txt'] + file_list.exclude_pattern('*.py') + assert file_list.files == ['a.txt'] + + def test_include_pattern(self): + # return False if no match + file_list = FileList() + file_list.set_allfiles([]) + assert not file_list.include_pattern('*.py') + + # return True if files match + file_list = FileList() + file_list.set_allfiles(['a.py', 'b.txt']) + assert file_list.include_pattern('*.py') + + # test * matches all files + file_list = FileList() + assert file_list.allfiles is None + file_list.set_allfiles(['a.py', 'b.txt']) + file_list.include_pattern('*') + assert file_list.allfiles == ['a.py', 'b.txt'] + + def test_process_template(self, caplog): + mlp = make_local_path + # invalid lines + file_list = FileList() + for action in ( + 'include', + 'exclude', + 'global-include', + 'global-exclude', + 'recursive-include', + 'recursive-exclude', + 'graft', + 'prune', + 'blarg', + ): + with pytest.raises(DistutilsTemplateError): + file_list.process_template_line(action) + + # include + file_list = FileList() + file_list.set_allfiles(['a.py', 'b.txt', mlp('d/c.py')]) + + file_list.process_template_line('include *.py') + assert file_list.files == ['a.py'] + self.assertNoWarnings(caplog) + + file_list.process_template_line('include *.rb') + assert file_list.files == ['a.py'] + self.assertWarnings(caplog) + + # exclude + file_list = FileList() + file_list.files = ['a.py', 'b.txt', mlp('d/c.py')] + + file_list.process_template_line('exclude *.py') + assert file_list.files == ['b.txt', mlp('d/c.py')] + self.assertNoWarnings(caplog) + + file_list.process_template_line('exclude *.rb') + assert file_list.files == ['b.txt', mlp('d/c.py')] + self.assertWarnings(caplog) + + # global-include + file_list = FileList() + file_list.set_allfiles(['a.py', 'b.txt', mlp('d/c.py')]) + + file_list.process_template_line('global-include *.py') + assert file_list.files == ['a.py', mlp('d/c.py')] + self.assertNoWarnings(caplog) + + file_list.process_template_line('global-include *.rb') + assert file_list.files == ['a.py', mlp('d/c.py')] + self.assertWarnings(caplog) + + # global-exclude + file_list = FileList() + file_list.files = ['a.py', 'b.txt', mlp('d/c.py')] + + file_list.process_template_line('global-exclude *.py') + assert file_list.files == ['b.txt'] + self.assertNoWarnings(caplog) + + file_list.process_template_line('global-exclude *.rb') + assert file_list.files == ['b.txt'] + self.assertWarnings(caplog) + + # recursive-include + file_list = FileList() + file_list.set_allfiles(['a.py', mlp('d/b.py'), mlp('d/c.txt'), mlp('d/d/e.py')]) + + file_list.process_template_line('recursive-include d *.py') + assert file_list.files == [mlp('d/b.py'), mlp('d/d/e.py')] + self.assertNoWarnings(caplog) + + file_list.process_template_line('recursive-include e *.py') + assert file_list.files == [mlp('d/b.py'), mlp('d/d/e.py')] + self.assertWarnings(caplog) + + # recursive-exclude + file_list = FileList() + file_list.files = ['a.py', mlp('d/b.py'), mlp('d/c.txt'), mlp('d/d/e.py')] + + file_list.process_template_line('recursive-exclude d *.py') + assert file_list.files == ['a.py', mlp('d/c.txt')] + self.assertNoWarnings(caplog) + + file_list.process_template_line('recursive-exclude e *.py') + assert file_list.files == ['a.py', mlp('d/c.txt')] + self.assertWarnings(caplog) + + # graft + file_list = FileList() + file_list.set_allfiles(['a.py', mlp('d/b.py'), mlp('d/d/e.py'), mlp('f/f.py')]) + + file_list.process_template_line('graft d') + assert file_list.files == [mlp('d/b.py'), mlp('d/d/e.py')] + self.assertNoWarnings(caplog) + + file_list.process_template_line('graft e') + assert file_list.files == [mlp('d/b.py'), mlp('d/d/e.py')] + self.assertWarnings(caplog) + + # prune + file_list = FileList() + file_list.files = ['a.py', mlp('d/b.py'), mlp('d/d/e.py'), mlp('f/f.py')] + + file_list.process_template_line('prune d') + assert file_list.files == ['a.py', mlp('f/f.py')] + self.assertNoWarnings(caplog) + + file_list.process_template_line('prune e') + assert file_list.files == ['a.py', mlp('f/f.py')] + self.assertWarnings(caplog) + + +class TestFindAll: + @os_helper.skip_unless_symlink + def test_missing_symlink(self, temp_cwd): + os.symlink('foo', 'bar') + assert filelist.findall() == [] + + def test_basic_discovery(self, temp_cwd): + """ + When findall is called with no parameters or with + '.' as the parameter, the dot should be omitted from + the results. + """ + jaraco.path.build({'foo': {'file1.txt': ''}, 'bar': {'file2.txt': ''}}) + file1 = os.path.join('foo', 'file1.txt') + file2 = os.path.join('bar', 'file2.txt') + expected = [file2, file1] + assert sorted(filelist.findall()) == expected + + def test_non_local_discovery(self, tmp_path): + """ + When findall is called with another path, the full + path name should be returned. + """ + jaraco.path.build({'file1.txt': ''}, tmp_path) + expected = [str(tmp_path / 'file1.txt')] + assert filelist.findall(tmp_path) == expected + + @os_helper.skip_unless_symlink + def test_symlink_loop(self, tmp_path): + jaraco.path.build( + { + 'link-to-parent': jaraco.path.Symlink('.'), + 'somefile': '', + }, + tmp_path, + ) + files = filelist.findall(tmp_path) + assert len(files) == 1 diff --git a/setuptools/_distutils/util.py b/setuptools/_distutils/util.py index 6dbe049f42..17e86feded 100644 --- a/setuptools/_distutils/util.py +++ b/setuptools/_distutils/util.py @@ -513,6 +513,6 @@ def is_mingw() -> bool: return sys.platform == 'win32' and get_platform().startswith('mingw') -def is_freethreaded(): +def is_freethreaded() -> bool: """Return True if the Python interpreter is built with free threading support.""" return bool(sysconfig.get_config_var('Py_GIL_DISABLED')) diff --git a/setuptools/_path.py b/setuptools/_path.py index 2b78022934..5525a0e66b 100644 --- a/setuptools/_path.py +++ b/setuptools/_path.py @@ -11,7 +11,9 @@ from typing_extensions import TypeAlias StrPath: TypeAlias = Union[str, os.PathLike[str]] # Same as _typeshed.StrPath -StrPathT = TypeVar("StrPathT", bound=Union[str, os.PathLike[str]]) +StrPathT = TypeVar("StrPathT", bound=StrPath) +BytesPath: TypeAlias = Union[bytes, os.PathLike[bytes]] # Same as _typeshed.BytesPath +BytesPathT = TypeVar("BytesPathT", bound=BytesPath) def ensure_directory(path): diff --git a/setuptools/command/bdist_egg.py b/setuptools/command/bdist_egg.py index ab452680f1..66cb0c798b 100644 --- a/setuptools/command/bdist_egg.py +++ b/setuptools/command/bdist_egg.py @@ -133,7 +133,7 @@ def do_install_data(self) -> None: site_packages = os.path.normcase(os.path.realpath(_get_purelib())) old, self.distribution.data_files = self.distribution.data_files, [] - for item in old: + for item in old or (): if isinstance(item, tuple) and len(item) == 2: if os.path.isabs(item[0]): realpath = os.path.realpath(item[0]) @@ -235,7 +235,7 @@ def run(self) -> None: # noqa: C901 # is too complex (14) # FIXME self.egg_output, archive_root, verbose=self.verbose, - dry_run=self.dry_run, # type: ignore[arg-type] # Is an actual boolean in vendored _distutils + dry_run=self.dry_run, mode=self.gen_header(), ) if not self.keep_temp: diff --git a/setuptools/command/build_py.py b/setuptools/command/build_py.py index 3c7c2d1bd6..90fbdbccdf 100644 --- a/setuptools/command/build_py.py +++ b/setuptools/command/build_py.py @@ -10,11 +10,11 @@ from functools import partial from glob import glob from pathlib import Path -from typing import Any +from typing import Any, Literal, overload from more_itertools import unique_everseen -from .._path import StrPath, StrPathT +from .._path import BytesPath, BytesPathT, StrPath, StrPathT from ..dist import Distribution from ..warnings import SetuptoolsDeprecationWarning @@ -50,21 +50,50 @@ def finalize_options(self) -> None: if 'data_files' in self.__dict__: del self.__dict__['data_files'] - def copy_file( # type: ignore[override] # No overload, no bytes support + @overload # type: ignore[override] # Truthy link with bytes is not supported, unlike supertype + def copy_file( self, infile: StrPath, outfile: StrPathT, preserve_mode: bool = True, preserve_times: bool = True, link: str | None = None, - level: object = 1, - ) -> tuple[StrPathT | str, bool]: + level: int = 1, + ) -> tuple[StrPathT | str, bool]: ... + @overload + def copy_file( + self, + infile: BytesPath, + outfile: BytesPathT, + preserve_mode: bool = True, + preserve_times: bool = True, + link: Literal[""] | None = None, + level: int = 1, + ) -> tuple[BytesPathT | bytes, bool]: ... + def copy_file( + self, + infile: StrPath | BytesPath, + outfile: StrPath | BytesPath, + preserve_mode: bool = True, + preserve_times: bool = True, + link: str | None = None, + level: int = 1, + ) -> tuple[StrPath | BytesPath, bool]: # Overwrite base class to allow using links if link: - infile = str(Path(infile).resolve()) - outfile = str(Path(outfile).resolve()) # type: ignore[assignment] # Re-assigning a str when outfile is StrPath is ok - return super().copy_file( # pyright: ignore[reportReturnType] # pypa/distutils#309 - infile, outfile, preserve_mode, preserve_times, link, level + # NOTE: Explanation for the type ignores: + # 1. If link is truthy, then we only allow infile and outfile to be StrPath + # 2. Re-assigning a str when outfile is StrPath is ok + # We can't easily check for PathLike[str], so ignoring instead of asserting. + infile = str(Path(infile).resolve()) # type: ignore[arg-type] + outfile = str(Path(outfile).resolve()) # type: ignore[arg-type] + return super().copy_file( # type: ignore[misc, type-var] # pyright: ignore[reportCallIssue] + infile, # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + outfile, # pyright: ignore[reportArgumentType] + preserve_mode, + preserve_times, + link, + level, ) def run(self) -> None: @@ -138,7 +167,7 @@ def find_data_files(self, package, src_dir): ) return self.exclude_data_files(package, src_dir, files) - def get_outputs(self, include_bytecode: bool = True) -> list[str]: # type: ignore[override] # Using a real boolean instead of 0|1 + def get_outputs(self, include_bytecode: bool = True) -> list[str]: """See :class:`setuptools.commands.build.SubCommand`""" if self.editable_mode: return list(self.get_output_mapping().keys()) diff --git a/setuptools/command/install_lib.py b/setuptools/command/install_lib.py index 8e1e072710..ef76286d4f 100644 --- a/setuptools/command/install_lib.py +++ b/setuptools/command/install_lib.py @@ -95,10 +95,9 @@ def copy_tree( self, infile: StrPath, outfile: str, - # override: Using actual booleans - preserve_mode: bool = True, # type: ignore[override] - preserve_times: bool = True, # type: ignore[override] - preserve_symlinks: bool = False, # type: ignore[override] + preserve_mode: bool = True, + preserve_times: bool = True, + preserve_symlinks: bool = False, level: object = 1, ) -> list[str]: assert preserve_mode diff --git a/setuptools/command/setopt.py b/setuptools/command/setopt.py index 43cb593999..f04f2e6426 100644 --- a/setuptools/command/setopt.py +++ b/setuptools/command/setopt.py @@ -37,7 +37,7 @@ def edit_config(filename, settings, dry_run=False) -> None: """ log.debug("Reading configuration from %s", filename) opts = configparser.RawConfigParser() - opts.optionxform = lambda optionstr: optionstr # type: ignore[method-assign] # overriding method + opts.optionxform = lambda optionstr: optionstr # type: ignore[method-assign] _cfg_read_utf8_with_fallback(opts, filename) for section, options in settings.items(): diff --git a/setuptools/config/setupcfg.py b/setuptools/config/setupcfg.py index 121a0febda..55fff4bc84 100644 --- a/setuptools/config/setupcfg.py +++ b/setuptools/config/setupcfg.py @@ -18,7 +18,7 @@ from collections import defaultdict from collections.abc import Iterable, Iterator from functools import partial, wraps -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, TypeVar from packaging.markers import default_environment as marker_env from packaging.requirements import InvalidRequirement, Requirement @@ -102,8 +102,7 @@ def _apply( filenames = [*other_files, filepath] try: - # TODO: Temporary cast until mypy 1.12 is released with upstream fixes from typeshed - _Distribution.parse_config_files(dist, filenames=cast(list[str], filenames)) + _Distribution.parse_config_files(dist, filenames=filenames) handlers = parse_configuration( dist, dist.command_options, ignore_option_errors=ignore_option_errors ) diff --git a/setuptools/dist.py b/setuptools/dist.py index a3d1e5f91e..d1e9953294 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -46,6 +46,7 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias + from .extension import Extension __all__ = ['Distribution'] @@ -299,6 +300,9 @@ class Distribution(_Distribution): # Used by build_py, editable_wheel and install_lib commands for legacy namespaces namespace_packages: list[str] #: :meta private: DEPRECATED + # override distutils.extension.Extension with setuptools.extension.Extension + ext_modules: list[Extension] | None # type: ignore[assignment] + # Any: Dynamic assignment results in Incompatible types in assignment def __init__(self, attrs: MutableMapping[str, Any] | None = None) -> None: have_package_data = hasattr(self, "package_data") @@ -836,7 +840,7 @@ def fetch_build_egg(self, req): return fetch_build_egg(self, req) - def get_command_class(self, command: str) -> type[distutils.cmd.Command]: # type: ignore[override] # Not doing complex overrides yet + def get_command_class(self, command: str) -> type[distutils.cmd.Command]: """Pluggable version of get_command_class()""" if command in self.cmdclass: return self.cmdclass[command] diff --git a/setuptools/errors.py b/setuptools/errors.py index 990ecbf4e2..fa580085ac 100644 --- a/setuptools/errors.py +++ b/setuptools/errors.py @@ -3,6 +3,8 @@ Provides exceptions used by setuptools modules. """ +# Odd mypy issue with this specific import, alias and base classes on 3.12+ +# mypy: disable-error-code="valid-type,misc" from __future__ import annotations from distutils import errors as _distutils_errors @@ -30,15 +32,15 @@ BaseError = _distutils_errors.DistutilsError -class InvalidConfigError(OptionError): # type: ignore[valid-type, misc] # distutils imports are `Any` on python 3.12+ +class InvalidConfigError(OptionError): """Error used for invalid configurations.""" -class RemovedConfigError(OptionError): # type: ignore[valid-type, misc] # distutils imports are `Any` on python 3.12+ +class RemovedConfigError(OptionError): """Error used for configurations that were deprecated and removed.""" -class RemovedCommandError(BaseError, RuntimeError): # type: ignore[valid-type, misc] # distutils imports are `Any` on python 3.12+ +class RemovedCommandError(BaseError, RuntimeError): """Error used for commands that have been removed in setuptools. Since ``setuptools`` is built on ``distutils``, simply removing a command @@ -48,7 +50,7 @@ class RemovedCommandError(BaseError, RuntimeError): # type: ignore[valid-type, """ -class PackageDiscoveryError(BaseError, RuntimeError): # type: ignore[valid-type, misc] # distutils imports are `Any` on python 3.12+ +class PackageDiscoveryError(BaseError, RuntimeError): """Impossible to perform automatic discovery of packages and/or modules. The current project layout or given discovery options can lead to problems when diff --git a/setuptools/extension.py b/setuptools/extension.py index 3e63cbe12a..5a09477dc7 100644 --- a/setuptools/extension.py +++ b/setuptools/extension.py @@ -153,12 +153,7 @@ def __init__( # The *args is needed for compatibility as calls may use positional # arguments. py_limited_api may be set only via keyword. self.py_limited_api = py_limited_api - super().__init__( - name, - sources, # type: ignore[arg-type] # Vendored version of setuptools supports PathLike - *args, - **kw, - ) + super().__init__(name, sources, *args, **kw) def _convert_pyx_sources_to_lang(self): """ diff --git a/setuptools/logging.py b/setuptools/logging.py index 532da899f7..7601ded6e8 100644 --- a/setuptools/logging.py +++ b/setuptools/logging.py @@ -32,7 +32,7 @@ def configure() -> None: # and then loaded again when patched, # implying: id(distutils.log) != id(distutils.dist.log). # Make sure the same module object is used everywhere: - distutils.dist.log = distutils.log + distutils.dist.log = distutils.log # type: ignore[assignment] def set_threshold(level: int) -> int: diff --git a/setuptools/monkey.py b/setuptools/monkey.py index 24bb8180f9..9210efc3bb 100644 --- a/setuptools/monkey.py +++ b/setuptools/monkey.py @@ -73,7 +73,7 @@ def patch_all() -> None: import setuptools # we can't patch distutils.cmd, alas - distutils.core.Command = setuptools.Command # type: ignore[misc,assignment] # monkeypatching + distutils.core.Command = setuptools.Command # type: ignore[misc, assignment] # monkeypatching _patch_distribution_metadata() @@ -82,8 +82,8 @@ def patch_all() -> None: module.Distribution = setuptools.dist.Distribution # Install the patched Extension - distutils.core.Extension = setuptools.extension.Extension # type: ignore[misc,assignment] # monkeypatching - distutils.extension.Extension = setuptools.extension.Extension # type: ignore[misc,assignment] # monkeypatching + distutils.core.Extension = setuptools.extension.Extension # type: ignore[misc] # monkeypatching + distutils.extension.Extension = setuptools.extension.Extension # type: ignore[misc] # monkeypatching if 'distutils.command.build_ext' in sys.modules: sys.modules[ 'distutils.command.build_ext' diff --git a/setuptools/msvc.py b/setuptools/msvc.py index f506c8222d..bcd5c059aa 100644 --- a/setuptools/msvc.py +++ b/setuptools/msvc.py @@ -293,7 +293,7 @@ def windows_kits_roots(self) -> LiteralString: @overload def microsoft(self, key: LiteralString, x86: bool = False) -> LiteralString: ... @overload - def microsoft(self, key: str, x86: bool = False) -> str: ... # type: ignore[misc] + def microsoft(self, key: str, x86: bool = False) -> str: ... # type: ignore[overload-cannot-match] def microsoft(self, key: str, x86: bool = False) -> str: """ Return key in Microsoft software registry. diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py index 8f48c4316d..52ee329c88 100644 --- a/setuptools/tests/config/test_apply_pyprojecttoml.py +++ b/setuptools/tests/config/test_apply_pyprojecttoml.py @@ -550,6 +550,7 @@ def test_pyproject_sets_attribute(self, tmp_path, monkeypatch): pyproject.write_text(cleandoc(toml_config), encoding="utf-8") with pytest.warns(pyprojecttoml._ExperimentalConfiguration): dist = pyprojecttoml.apply_configuration(Distribution({}), pyproject) + assert dist.ext_modules assert len(dist.ext_modules) == 1 assert dist.ext_modules[0].name == "my.ext" assert set(dist.ext_modules[0].sources) == {"hello.c", "world.c"} diff --git a/setuptools/tests/test_config_discovery.py b/setuptools/tests/test_config_discovery.py index b5df8203cd..18d7bd57ea 100644 --- a/setuptools/tests/test_config_discovery.py +++ b/setuptools/tests/test_config_discovery.py @@ -389,6 +389,7 @@ def test_skip_discovery_with_setupcfg_metadata(self, tmp_path): assert dist.get_version() == "42" assert dist.py_modules is None assert dist.packages is None + assert dist.ext_modules assert len(dist.ext_modules) == 1 assert dist.ext_modules[0].name == "proj"