diff options
Diffstat (limited to 'src/build/__main__.py')
-rw-r--r-- | src/build/__main__.py | 397 |
1 files changed, 397 insertions, 0 deletions
diff --git a/src/build/__main__.py b/src/build/__main__.py new file mode 100644 index 0000000..67b21d1 --- /dev/null +++ b/src/build/__main__.py @@ -0,0 +1,397 @@ +# SPDX-License-Identifier: MIT + + +import argparse +import contextlib +import os +import platform +import shutil +import subprocess +import sys +import tarfile +import tempfile +import textwrap +import traceback +import warnings + +from typing import Dict, Iterator, List, NoReturn, Optional, Sequence, TextIO, Type, Union + +import build + +from build import BuildBackendException, BuildException, ConfigSettingsType, FailedProcessError, PathType, ProjectBuilder +from build.env import IsolatedEnvBuilder + + +_COLORS = { + 'red': '\33[91m', + 'green': '\33[92m', + 'yellow': '\33[93m', + 'bold': '\33[1m', + 'dim': '\33[2m', + 'underline': '\33[4m', + 'reset': '\33[0m', +} +_NO_COLORS = {color: '' for color in _COLORS} + + +def _init_colors() -> Dict[str, str]: + if 'NO_COLOR' in os.environ: + if 'FORCE_COLOR' in os.environ: + warnings.warn('Both NO_COLOR and FORCE_COLOR environment variables are set, disabling color') + return _NO_COLORS + elif 'FORCE_COLOR' in os.environ or sys.stdout.isatty(): + return _COLORS + return _NO_COLORS + + +_STYLES = _init_colors() + + +def _cprint(fmt: str = '', msg: str = '') -> None: + print(fmt.format(msg, **_STYLES), flush=True) + + +def _showwarning( + message: Union[Warning, str], + category: Type[Warning], + filename: str, + lineno: int, + file: Optional[TextIO] = None, + line: Optional[str] = None, +) -> None: # pragma: no cover + _cprint('{yellow}WARNING{reset} {}', str(message)) + + +def _setup_cli() -> None: + warnings.showwarning = _showwarning + + if platform.system() == 'Windows': + try: + import colorama + + colorama.init() + except ModuleNotFoundError: + pass + + +def _error(msg: str, code: int = 1) -> NoReturn: # pragma: no cover + """ + Print an error message and exit. Will color the output when writing to a TTY. + + :param msg: Error message + :param code: Error code + """ + _cprint('{red}ERROR{reset} {}', msg) + raise SystemExit(code) + + +class _ProjectBuilder(ProjectBuilder): + @staticmethod + def log(message: str) -> None: + _cprint('{bold}* {}{reset}', message) + + +class _IsolatedEnvBuilder(IsolatedEnvBuilder): + @staticmethod + def log(message: str) -> None: + _cprint('{bold}* {}{reset}', message) + + +def _format_dep_chain(dep_chain: Sequence[str]) -> str: + return ' -> '.join(dep.partition(';')[0].strip() for dep in dep_chain) + + +def _build_in_isolated_env( + builder: ProjectBuilder, outdir: PathType, distribution: str, config_settings: Optional[ConfigSettingsType] +) -> str: + with _IsolatedEnvBuilder() as env: + builder.python_executable = env.executable + builder.scripts_dir = env.scripts_dir + # first install the build dependencies + env.install(builder.build_system_requires) + # then get the extra required dependencies from the backend (which was installed in the call above :P) + env.install(builder.get_requires_for_build(distribution)) + return builder.build(distribution, outdir, config_settings or {}) + + +def _build_in_current_env( + builder: ProjectBuilder, + outdir: PathType, + distribution: str, + config_settings: Optional[ConfigSettingsType], + skip_dependency_check: bool = False, +) -> str: + if not skip_dependency_check: + missing = builder.check_dependencies(distribution) + if missing: + dependencies = ''.join('\n\t' + dep for deps in missing for dep in (deps[0], _format_dep_chain(deps[1:])) if dep) + _cprint() + _error(f'Missing dependencies:{dependencies}') + + return builder.build(distribution, outdir, config_settings or {}) + + +def _build( + isolation: bool, + builder: ProjectBuilder, + outdir: PathType, + distribution: str, + config_settings: Optional[ConfigSettingsType], + skip_dependency_check: bool, +) -> str: + if isolation: + return _build_in_isolated_env(builder, outdir, distribution, config_settings) + else: + return _build_in_current_env(builder, outdir, distribution, config_settings, skip_dependency_check) + + +@contextlib.contextmanager +def _handle_build_error() -> Iterator[None]: + try: + yield + except (BuildException, FailedProcessError) as e: + _error(str(e)) + except BuildBackendException as e: + if isinstance(e.exception, subprocess.CalledProcessError): + _cprint() + _error(str(e)) + + if e.exc_info: + tb_lines = traceback.format_exception( + e.exc_info[0], + e.exc_info[1], + e.exc_info[2], + limit=-1, + ) + tb = ''.join(tb_lines) + else: + tb = traceback.format_exc(-1) + _cprint('\n{dim}{}{reset}\n', tb.strip('\n')) + _error(str(e)) + + +def _natural_language_list(elements: Sequence[str]) -> str: + if len(elements) == 0: + raise IndexError('no elements') + elif len(elements) == 1: + return elements[0] + else: + return '{} and {}'.format( + ', '.join(elements[:-1]), + elements[-1], + ) + + +def build_package( + srcdir: PathType, + outdir: PathType, + distributions: Sequence[str], + config_settings: Optional[ConfigSettingsType] = None, + isolation: bool = True, + skip_dependency_check: bool = False, +) -> Sequence[str]: + """ + Run the build process. + + :param srcdir: Source directory + :param outdir: Output directory + :param distribution: Distribution to build (sdist or wheel) + :param config_settings: Configuration settings to be passed to the backend + :param isolation: Isolate the build in a separate environment + :param skip_dependency_check: Do not perform the dependency check + """ + built: List[str] = [] + builder = _ProjectBuilder(srcdir) + for distribution in distributions: + out = _build(isolation, builder, outdir, distribution, config_settings, skip_dependency_check) + built.append(os.path.basename(out)) + return built + + +def build_package_via_sdist( + srcdir: PathType, + outdir: PathType, + distributions: Sequence[str], + config_settings: Optional[ConfigSettingsType] = None, + isolation: bool = True, + skip_dependency_check: bool = False, +) -> Sequence[str]: + """ + Build a sdist and then the specified distributions from it. + + :param srcdir: Source directory + :param outdir: Output directory + :param distribution: Distribution to build (only wheel) + :param config_settings: Configuration settings to be passed to the backend + :param isolation: Isolate the build in a separate environment + :param skip_dependency_check: Do not perform the dependency check + """ + if 'sdist' in distributions: + raise ValueError('Only binary distributions are allowed but sdist was specified') + + builder = _ProjectBuilder(srcdir) + sdist = _build(isolation, builder, outdir, 'sdist', config_settings, skip_dependency_check) + + sdist_name = os.path.basename(sdist) + sdist_out = tempfile.mkdtemp(prefix='build-via-sdist-') + built: List[str] = [] + # extract sdist + with tarfile.open(sdist) as t: + t.extractall(sdist_out) + try: + builder = _ProjectBuilder(os.path.join(sdist_out, sdist_name[: -len('.tar.gz')])) + if distributions: + builder.log(f'Building {_natural_language_list(distributions)} from sdist') + for distribution in distributions: + out = _build(isolation, builder, outdir, distribution, config_settings, skip_dependency_check) + built.append(os.path.basename(out)) + finally: + shutil.rmtree(sdist_out, ignore_errors=True) + return [sdist_name] + built + + +def main_parser() -> argparse.ArgumentParser: + """ + Construct the main parser. + """ + parser = argparse.ArgumentParser( + description=textwrap.indent( + textwrap.dedent( + ''' + A simple, correct PEP 517 build frontend. + + By default, a source distribution (sdist) is built from {srcdir} + and a binary distribution (wheel) is built from the sdist. + This is recommended as it will ensure the sdist can be used + to build wheels. + + Pass -s/--sdist and/or -w/--wheel to build a specific distribution. + If you do this, the default behavior will be disabled, and all + artifacts will be built from {srcdir} (even if you combine + -w/--wheel with -s/--sdist, the wheel will be built from {srcdir}). + ''' + ).strip(), + ' ', + ), + formatter_class=argparse.RawTextHelpFormatter, + ) + parser.add_argument( + 'srcdir', + type=str, + nargs='?', + default=os.getcwd(), + help='source directory (defaults to current directory)', + ) + parser.add_argument( + '--version', + '-V', + action='version', + version=f"build {build.__version__} ({','.join(build.__path__)})", + ) + parser.add_argument( + '--sdist', + '-s', + action='store_true', + help='build a source distribution (disables the default behavior)', + ) + parser.add_argument( + '--wheel', + '-w', + action='store_true', + help='build a wheel (disables the default behavior)', + ) + parser.add_argument( + '--outdir', + '-o', + type=str, + help=f'output directory (defaults to {{srcdir}}{os.sep}dist)', + ) + parser.add_argument( + '--skip-dependency-check', + '-x', + action='store_true', + help='do not check that build dependencies are installed', + ) + parser.add_argument( + '--no-isolation', + '-n', + action='store_true', + help='do not isolate the build in a virtual environment', + ) + parser.add_argument( + '--config-setting', + '-C', + action='append', + help='pass options to the backend. options which begin with a hyphen must be in the form of ' + '"--config-setting=--opt(=value)" or "-C--opt(=value)"', + ) + return parser + + +def main(cli_args: Sequence[str], prog: Optional[str] = None) -> None: # noqa: C901 + """ + Parse the CLI arguments and invoke the build process. + + :param cli_args: CLI arguments + :param prog: Program name to show in help text + """ + _setup_cli() + parser = main_parser() + if prog: + parser.prog = prog + args = parser.parse_args(cli_args) + + distributions = [] + config_settings = {} + + if args.config_setting: + for arg in args.config_setting: + setting, _, value = arg.partition('=') + if setting not in config_settings: + config_settings[setting] = value + else: + if not isinstance(config_settings[setting], list): + config_settings[setting] = [config_settings[setting]] + + config_settings[setting].append(value) + + if args.sdist: + distributions.append('sdist') + if args.wheel: + distributions.append('wheel') + + # outdir is relative to srcdir only if omitted. + outdir = os.path.join(args.srcdir, 'dist') if args.outdir is None else args.outdir + + if distributions: + build_call = build_package + else: + build_call = build_package_via_sdist + distributions = ['wheel'] + try: + with _handle_build_error(): + built = build_call( + args.srcdir, outdir, distributions, config_settings, not args.no_isolation, args.skip_dependency_check + ) + artifact_list = _natural_language_list( + ['{underline}{}{reset}{bold}{green}'.format(artifact, **_STYLES) for artifact in built] + ) + _cprint('{bold}{green}Successfully built {}{reset}', artifact_list) + except Exception as e: # pragma: no cover + tb = traceback.format_exc().strip('\n') + _cprint('\n{dim}{}{reset}\n', tb) + _error(str(e)) + + +def entrypoint() -> None: + main(sys.argv[1:]) + + +if __name__ == '__main__': # pragma: no cover + main(sys.argv[1:], 'python -m build') + + +__all__ = [ + 'main', + 'main_parser', +] |