codekingpro/portable-devtools
114k
1# SPDX-License-Identifier: MIT2 3from __future__ import annotations4 5 6__lazy_modules__ = [7 'argparse',8 'build._exceptions',9 'build.env',10 'functools',11 'json',12 'os',13 'platform',14 'pyproject_hooks',15 'shutil',16 'subprocess',17 'tempfile',18 'textwrap',19 'traceback',20 'typing',21 'warnings',22]23 24import argparse25import contextlib26import contextvars27import json28import os29import platform30import shutil31import subprocess32import sys33import tempfile34import textwrap35import traceback36import warnings37 38from functools import partial39from typing import Any, NoReturn, TextIO40 41import pyproject_hooks42 43import build44import build.env as _env45 46from build import ProjectBuilder, _ctx47from build._exceptions import BuildBackendException, BuildException, FailedProcessError48from build.env import DefaultIsolatedEnv49 50 51TYPE_CHECKING = False52 53if TYPE_CHECKING:54 from collections.abc import Iterator, Mapping, Sequence55 56 from build._types import ConfigSettings, Distribution, StrPath, SubprocessRunner57 58 59_COLORS = {60 'red': '\33[91m',61 'green': '\33[92m',62 'yellow': '\33[93m',63 'bold': '\33[1m',64 'dim': '\33[2m',65 'underline': '\33[4m',66 'reset': '\33[0m',67}68_NO_COLORS = dict.fromkeys(_COLORS, '')69 70 71_styles = contextvars.ContextVar('_styles', default=_COLORS)72 73 74def _init_colors() -> None:75 if 'NO_COLOR' in os.environ:76 if 'FORCE_COLOR' in os.environ:77 warnings.warn('Both NO_COLOR and FORCE_COLOR environment variables are set, disabling color', stacklevel=2)78 _styles.set(_NO_COLORS)79 elif 'FORCE_COLOR' in os.environ or sys.stdout.isatty():80 return81 _styles.set(_NO_COLORS)82 83 84def _cprint(fmt: str = '', msg: str = '', file: TextIO | None = None) -> None:85 print(fmt.format(msg, **_styles.get()), file=file, flush=True)86 87 88def _showwarning(89 message: Warning | str,90 category: type[Warning], # noqa: ARG00191 filename: str, # noqa: ARG00192 lineno: int, # noqa: ARG00193 file: TextIO | None = None,94 line: str | None = None, # noqa: ARG00195) -> None: # pragma: no cover96 _cprint('{yellow}WARNING{reset} {}', str(message), file)97 98 99def _make_logger() -> _ctx.Logger:100 max_terminal_width = shutil.get_terminal_size().columns - 2101 if max_terminal_width <= 0: # pragma: no cover102 max_terminal_width = 78103 104 fill = partial(textwrap.fill, subsequent_indent=' ', width=max_terminal_width)105 106 def log(message: str, *, kind: tuple[str, ...] | None = None) -> None:107 if _ctx.verbosity >= -1:108 match kind:109 case ('step', *_):110 (first, *rest) = message.splitlines()111 _cprint('{bold}{}{reset}', fill(first, initial_indent='* '), file=sys.stderr)112 for line in rest:113 print(fill(line, initial_indent=' '), file=sys.stderr) # noqa: T201114 case ('subprocess', 'cmd'):115 for line in message.splitlines():116 _cprint('{dim}{}{reset}', fill(line, initial_indent='> '), file=sys.stderr)117 case ('subprocess', 'stdout' | 'stderr'):118 for line in message.splitlines():119 _cprint('{dim}{}{reset}', fill(line, initial_indent='< '), file=sys.stderr)120 case _:121 print(fill(message, initial_indent=' '), file=sys.stderr) # noqa: T201122 123 return log124 125 126def _setup_cli(*, verbosity: int) -> None:127 warnings.showwarning = _showwarning128 129 if platform.system() == 'Windows':130 try:131 import colorama132 133 colorama.init()134 except ModuleNotFoundError:135 pass136 137 _init_colors()138 139 _ctx.LOGGER.set(_make_logger())140 _ctx.VERBOSITY.set(verbosity)141 142 143def _error(msg: str, code: int = 1) -> NoReturn: # pragma: no cover144 """145 Print an error message and exit. Will color the output when writing to a TTY.146 147 :param msg: Error message148 :param code: Error code149 """150 _cprint('{red}ERROR{reset} {}', msg, file=sys.stderr)151 raise SystemExit(code)152 153 154def _format_dep_chain(dep_chain: tuple[str, ...]) -> str:155 return ' -> '.join(dep.partition(';')[0].strip() for dep in dep_chain)156 157 158@contextlib.contextmanager159def _bootstrap_build_env(160 isolation: bool,161 srcdir: StrPath,162 distribution: Distribution,163 config_settings: ConfigSettings | None,164 skip_dependency_check: bool,165 dependency_constraints_txt: os.PathLike[str] | None,166 installer: _env.Installer,167 runner: SubprocessRunner | None = None,168) -> Iterator[ProjectBuilder]:169 if isolation:170 with DefaultIsolatedEnv(installer=installer) as env:171 make_builder = partial(ProjectBuilder.from_isolated_env, env, srcdir)172 if runner:173 make_builder = partial(make_builder, runner=runner)174 builder = make_builder()175 176 install = env.install177 if dependency_constraints_txt:178 with open(dependency_constraints_txt, encoding='utf-8') as dependency_constraints_file:179 install = partial(install, constraints=set(map(str.strip, dependency_constraints_file)))180 181 # first install the build dependencies182 install(builder.build_system_requires, _fresh=True)183 # then get the extra required dependencies from the backend (which was installed in the call above :P)184 install(builder.get_requires_for_build(distribution, config_settings))185 186 yield builder187 188 else:189 make_builder = partial(ProjectBuilder, srcdir)190 if runner:191 make_builder = partial(make_builder, runner=runner)192 builder = make_builder()193 194 if not skip_dependency_check:195 missing = builder.check_dependencies(distribution, config_settings)196 if missing:197 dependencies = ''.join('\n\t' + _format_dep_chain(deps) for deps in missing)198 _cprint()199 _error(f'Missing dependencies:{dependencies}')200 201 yield builder202 203 204def _build(205 isolation: bool,206 srcdir: StrPath,207 outdir: StrPath,208 distribution: Distribution,209 config_settings: ConfigSettings | None,210 skip_dependency_check: bool,211 dependency_constraints_txt: os.PathLike[str] | None,212 installer: _env.Installer,213) -> str:214 with _bootstrap_build_env(215 isolation,216 srcdir,217 distribution,218 config_settings,219 skip_dependency_check,220 dependency_constraints_txt,221 installer,222 pyproject_hooks.quiet_subprocess_runner if _ctx.verbosity < 0 else None,223 ) as builder:224 return builder.build(distribution, outdir, config_settings)225 226 227@contextlib.contextmanager228def _handle_build_error() -> Iterator[None]:229 try:230 yield231 except (BuildException, FailedProcessError) as e:232 _error(str(e))233 except BuildBackendException as e:234 if isinstance(e.exception, subprocess.CalledProcessError):235 _cprint()236 _error(str(e))237 238 if e.exc_info:239 tb_lines = traceback.format_exception(240 e.exc_info[0],241 e.exc_info[1],242 e.exc_info[2],243 limit=-1,244 )245 tb = ''.join(tb_lines)246 else: # pragma: no cover247 tb = traceback.format_exc(-1) # type: ignore[unreachable]248 _cprint('\n{dim}{}{reset}\n', tb.strip('\n'))249 _error(str(e))250 except Exception as e: # pragma: no cover251 tb = traceback.format_exc().strip('\n')252 _cprint('\n{dim}{}{reset}\n', tb)253 _error(str(e))254 255 256def _natural_language_list(elements: Sequence[str]) -> str:257 if len(elements) == 0:258 msg = 'no elements'259 raise IndexError(msg)260 if len(elements) == 1:261 return elements[0]262 return '{} and {}'.format(263 ', '.join(elements[:-1]),264 elements[-1],265 )266 267 268def build_package(269 srcdir: StrPath,270 outdir: StrPath,271 distributions: Sequence[Distribution],272 config_settings: ConfigSettings | None = None,273 isolation: bool = True,274 skip_dependency_check: bool = False,275 dependency_constraints_txt: os.PathLike[str] | None = None,276 installer: _env.Installer = 'pip',277) -> list[str]:278 """279 Run the build process.280 281 :param srcdir: Source directory282 :param outdir: Output directory283 :param distribution: Distribution to build (sdist or wheel)284 :param config_settings: Configuration settings to be passed to the backend285 :param isolation: Isolate the build in a separate environment286 :param skip_dependency_check: Do not perform the dependency check287 """288 built: list[str] = []289 for distribution in distributions:290 out = _build(291 isolation,292 srcdir,293 outdir,294 distribution,295 config_settings,296 skip_dependency_check,297 dependency_constraints_txt,298 installer,299 )300 built.append(os.path.basename(out))301 return built302 303 304def build_package_via_sdist(305 srcdir: StrPath,306 outdir: StrPath,307 distributions: Sequence[Distribution],308 config_settings: ConfigSettings | None = None,309 isolation: bool = True,310 skip_dependency_check: bool = False,311 dependency_constraints_txt: os.PathLike[str] | None = None,312 installer: _env.Installer = 'pip',313) -> list[str]:314 """315 Build a sdist and then the specified distributions from it.316 317 :param srcdir: Source directory318 :param outdir: Output directory319 :param distribution: Distribution to build (only wheel)320 :param config_settings: Configuration settings to be passed to the backend321 :param isolation: Isolate the build in a separate environment322 :param skip_dependency_check: Do not perform the dependency check323 """324 from ._compat import tarfile325 326 if 'sdist' in distributions:327 msg = 'Only binary distributions are allowed but sdist was specified'328 raise ValueError(msg)329 330 sdist = _build(331 isolation, srcdir, outdir, 'sdist', config_settings, skip_dependency_check, dependency_constraints_txt, installer332 )333 334 sdist_name = os.path.basename(sdist)335 sdist_out = tempfile.mkdtemp(prefix='build-via-sdist-')336 built: list[str] = []337 if distributions:338 # extract sdist339 with tarfile.TarFile.open(sdist) as t:340 t.extractall(sdist_out)341 try:342 _ctx.log(f'Building {_natural_language_list(distributions)} from sdist', kind=('step',))343 srcdir = os.path.join(sdist_out, sdist_name[: -len('.tar.gz')])344 for distribution in distributions:345 out = _build(346 isolation,347 srcdir,348 outdir,349 distribution,350 config_settings,351 skip_dependency_check,352 dependency_constraints_txt,353 installer,354 )355 built.append(os.path.basename(out))356 finally:357 shutil.rmtree(sdist_out, ignore_errors=True)358 return [sdist_name, *built]359 360 361def _build_metadata(362 srcdir: StrPath,363 outdir: StrPath, # noqa: ARG001364 distributions: Sequence[Distribution], # noqa: ARG001365 config_settings: ConfigSettings | None = None,366 isolation: bool = True,367 skip_dependency_check: bool = False,368 dependency_constraints_txt: os.PathLike[str] | None = None,369 installer: _env.Installer = 'pip',370) -> list[str]:371 import packaging.metadata372 373 def run_subprocess(cmd: Sequence[StrPath], cwd: str | None = None, extra_environ: Mapping[str, str] | None = None) -> None:374 env = os.environ.copy()375 if extra_environ:376 env.update(extra_environ)377 _ctx.run_subprocess(cmd, cwd, env)378 379 with (380 _bootstrap_build_env(381 isolation,382 srcdir,383 'wheel',384 config_settings,385 skip_dependency_check,386 dependency_constraints_txt,387 installer,388 runner=run_subprocess,389 ) as builder,390 tempfile.TemporaryDirectory() as tempdir,391 open(392 os.path.join(builder.metadata_path(tempdir), 'METADATA'),393 'rb',394 ) as metadata_file,395 ):396 valid_metadata, _ = packaging.metadata.parse_email(metadata_file.read())397 print( # noqa: T201398 json.dumps(valid_metadata, ensure_ascii=False, indent=2),399 )400 401 return []402 403 404def main_parser() -> argparse.ArgumentParser:405 """406 Construct the main parser.407 """408 409 class NegativeCountAction(argparse.Action):410 def __init__(411 self,412 option_strings: Sequence[str],413 dest: str,414 default: Any = None,415 help: str | None = None,416 ) -> None:417 super().__init__(418 option_strings=option_strings,419 dest=dest,420 nargs=0,421 default=default,422 help=help,423 )424 425 def __call__(426 self,427 parser: argparse.ArgumentParser, # noqa: ARG002428 namespace: object,429 values: str | Sequence[Any] | None, # noqa: ARG002430 option_string: str | None = None, # noqa: ARG002431 ) -> None:432 setattr(namespace, self.dest, getattr(namespace, self.dest, 0) - 1)433 434 make_parser = partial(435 argparse.ArgumentParser,436 add_help=False,437 description="""\438 A simple, correct Python build frontend.439 440 By default, a source distribution (sdist) is built from the project root441 and a binary distribution (wheel) is built from the sdist.442 If this is undesirable, you can pass `--sdist` and/or `--wheel`443 to build distributions independently of each other.444 """.rstrip(),445 # Prevent argparse from taking up the entire width of the terminal window446 # which impedes readability. Also keep the description formatted.447 formatter_class=argparse.RawDescriptionHelpFormatter,448 )449 if sys.version_info >= (3, 14): # pragma: no branch450 make_parser = partial(make_parser, suggest_on_error=True) # pragma: no cover451 452 parser = make_parser()453 parser.add_argument(454 'srcdir',455 type=str,456 nargs='?',457 default=os.getcwd(),458 help='source directory (defaults to the current working directory)',459 )460 461 global_group = parser.add_argument_group('global options')462 global_group.add_argument(463 '-h',464 '--help',465 action='help',466 default=argparse.SUPPRESS,467 help='show this help message and exit',468 )469 global_group.add_argument(470 '--version',471 '-V',472 action='version',473 version=f'build {build.__version__} ({",".join(build.__path__)})',474 )475 verbosity_exclusive_group = global_group.add_mutually_exclusive_group()476 verbosity_exclusive_group.add_argument(477 '--quiet',478 '-q',479 dest='verbosity',480 action=NegativeCountAction,481 default=0,482 help='reduce verbosity',483 )484 verbosity_exclusive_group.add_argument(485 '--verbose',486 '-v',487 dest='verbosity',488 action='count',489 default=0,490 help='increase verbosity',491 )492 493 build_group = parser.add_argument_group('build options')494 build_group.add_argument(495 '--outdir',496 '-o',497 help=f'output directory (defaults to {{srcdir}}{os.sep}dist). Cannot be used together with ``--metadata``',498 metavar='PATH',499 )500 build_group.add_argument(501 '--sdist',502 '-s',503 dest='distributions',504 action='append_const',505 const='sdist',506 help='build a source distribution (disables the default behavior)',507 )508 build_group.add_argument(509 '--wheel',510 '-w',511 dest='distributions',512 action='append_const',513 const='wheel',514 help='build a wheel (disables the default behavior)',515 )516 build_group.add_argument(517 '--metadata',518 action='store_true',519 help="print out a wheel's metadata in JSON format. Cannot be used in conjunction with ``--sdist`` or ``--wheel``",520 )521 config_exclusive_group = build_group.add_mutually_exclusive_group()522 config_exclusive_group.add_argument(523 '--config-setting',524 '-C',525 dest='config_settings',526 action='append',527 help='settings to pass to the backend. Multiple settings can be provided. '528 'Settings beginning with a hyphen will erroneously be interpreted as options to build if separated '529 'by a space; use ``--config-setting=--my-setting -C--my-other-setting`` instead',530 metavar='KEY[=VALUE]',531 )532 config_exclusive_group.add_argument(533 '--config-json',534 help='settings to pass to the backend as a JSON object. '535 'This is an alternative to ``--config-setting`` that allows complex nested structures. '536 'Cannot be used together with ``--config-setting``',537 metavar='JSON_STRING',538 )539 540 install_group = parser.add_argument_group('installation options')541 env_exclusive_group = install_group.add_mutually_exclusive_group()542 env_exclusive_group.add_argument(543 '--installer',544 choices=_env.INSTALLERS,545 help='Python package installer to use (defaults to pip)',546 )547 env_exclusive_group.add_argument(548 '--no-isolation',549 '-n',550 action='store_true',551 help='disable building the project in an isolated virtual environment. '552 'Build dependencies must be installed separately when this option is used',553 )554 install_group.add_argument(555 '--dependency-constraints-txt',556 help='constrain build dependencies using a constraints.txt when installing dependencies',557 metavar='PATH',558 )559 install_group.add_argument(560 '--skip-dependency-check',561 '-x',562 action='store_true',563 help='do not check that build dependencies are installed',564 )565 566 return parser567 568 569def _parse_config_settings(raw_config_settings: list[str]) -> dict[str, Any]:570 config_settings = dict[str, Any]()571 572 for arg in raw_config_settings:573 setting, _, value = arg.partition('=')574 if setting not in config_settings:575 config_settings[setting] = value576 else:577 if not isinstance(config_settings[setting], list):578 config_settings[setting] = [config_settings[setting]]579 580 config_settings[setting].append(value)581 582 return config_settings583 584 585def main(cli_args: Sequence[str], prog: str | None = None) -> None:586 """587 Parse the CLI arguments and invoke the build process.588 589 :param cli_args: CLI arguments590 :param prog: Program name to show in help text591 """592 parser = main_parser()593 if prog:594 parser.prog = prog595 args = parser.parse_args(cli_args)596 597 _setup_cli(verbosity=args.verbosity)598 599 config_settings = dict[str, Any]()600 601 # Handle --config-json602 if args.config_json:603 try:604 config_settings = json.loads(args.config_json)605 if not isinstance(config_settings, dict):606 _error('--config-json must contain a JSON object (dict), not a list or primitive value')607 except json.JSONDecodeError as e:608 _error(f'Invalid JSON in --config-json: {e}')609 610 # Handle --config-setting (original logic)611 elif args.config_settings:612 config_settings = _parse_config_settings(args.config_settings)613 614 # outdir is relative to srcdir only if omitted.615 outdir = os.path.join(args.srcdir, 'dist') if args.outdir is None else args.outdir616 617 if args.metadata and args.distributions:618 parser.error('--metadata: not allowed with --sdist or --wheel')619 elif args.metadata:620 build = partial(_build_metadata, distributions=['wheel'])621 elif args.distributions:622 build = partial(build_package, distributions=args.distributions)623 else:624 build = partial(build_package_via_sdist, distributions=['wheel'])625 626 with _handle_build_error():627 built = build(628 args.srcdir,629 outdir,630 config_settings=config_settings,631 isolation=not args.no_isolation,632 skip_dependency_check=args.skip_dependency_check,633 dependency_constraints_txt=args.dependency_constraints_txt,634 installer=args.installer,635 )636 if _ctx.verbosity >= -1 and built:637 artifact_list = _natural_language_list(638 ['{underline}{}{reset}{bold}{green}'.format(artifact, **_styles.get()) for artifact in built]639 )640 _cprint('{bold}{green}Successfully built {}{reset}', artifact_list)641 642 643def entrypoint() -> None:644 main(sys.argv[1:])645 646 647if __name__ == '__main__': # pragma: no cover648 main(sys.argv[1:], 'python -m build')649 650 651__all__ = [652 'main',653 'main_parser',654]655 