1# SPDX-License-Identifier: MIT 2 3 4import argparse 5import contextlib 6import os 7import shutil 8import subprocess 9import sys 10import tarfile 11import tempfile 12import textwrap 13import traceback 14import warnings 15 16from typing import Dict, Iterable, Iterator, List, Optional, Sequence, TextIO, Type, Union 17 18import build 19 20from build import BuildBackendException, BuildException, ConfigSettingsType, ProjectBuilder 21from build.env import IsolatedEnvBuilder 22 23 24__all__ = ['build', 'main', 'main_parser'] 25 26 27_COLORS = { 28 'red': '\33[91m', 29 'green': '\33[92m', 30 'yellow': '\33[93m', 31 'bold': '\33[1m', 32 'dim': '\33[2m', 33 'underline': '\33[4m', 34 'reset': '\33[0m', 35} 36_NO_COLORS = {color: '' for color in _COLORS} 37 38 39def _init_colors() -> Dict[str, str]: 40 if 'NO_COLOR' in os.environ: 41 if 'FORCE_COLOR' in os.environ: 42 warnings.warn('Both NO_COLOR and FORCE_COLOR environment variables are set, disabling color') 43 return _NO_COLORS 44 elif 'FORCE_COLOR' in os.environ or sys.stdout.isatty(): 45 return _COLORS 46 return _NO_COLORS 47 48 49_STYLES = _init_colors() 50 51 52def _showwarning( 53 message: Union[Warning, str], 54 category: Type[Warning], 55 filename: str, 56 lineno: int, 57 file: Optional[TextIO] = None, 58 line: Optional[str] = None, 59) -> None: # pragma: no cover 60 print('{yellow}WARNING{reset} {}'.format(message, **_STYLES)) 61 62 63def _setup_cli() -> None: 64 warnings.showwarning = _showwarning 65 66 try: 67 import colorama 68 except ModuleNotFoundError: 69 pass 70 else: 71 colorama.init() # fix colors on windows 72 73 74def _error(msg: str, code: int = 1) -> None: # pragma: no cover 75 """ 76 Print an error message and exit. Will color the output when writing to a TTY. 77 78 :param msg: Error message 79 :param code: Error code 80 """ 81 print('{red}ERROR{reset} {}'.format(msg, **_STYLES)) 82 exit(code) 83 84 85class _ProjectBuilder(ProjectBuilder): 86 @staticmethod 87 def log(message: str) -> None: 88 print('{bold}* {}{reset}'.format(message, **_STYLES)) 89 90 91class _IsolatedEnvBuilder(IsolatedEnvBuilder): 92 @staticmethod 93 def log(message: str) -> None: 94 print('{bold}* {}{reset}'.format(message, **_STYLES)) 95 96 97def _format_dep_chain(dep_chain: Sequence[str]) -> str: 98 return ' -> '.join(dep.partition(';')[0].strip() for dep in dep_chain) 99 100 101def _build_in_isolated_env( 102 builder: ProjectBuilder, outdir: str, distribution: str, config_settings: Optional[ConfigSettingsType] 103) -> str: 104 with _IsolatedEnvBuilder() as env: 105 builder.python_executable = env.executable 106 builder.scripts_dir = env.scripts_dir 107 # first install the build dependencies 108 env.install(builder.build_system_requires) 109 # then get the extra required dependencies from the backend (which was installed in the call above :P) 110 env.install(builder.get_requires_for_build(distribution)) 111 return builder.build(distribution, outdir, config_settings or {}) 112 113 114def _build_in_current_env( 115 builder: ProjectBuilder, 116 outdir: str, 117 distribution: str, 118 config_settings: Optional[ConfigSettingsType], 119 skip_dependency_check: bool = False, 120) -> str: 121 if not skip_dependency_check: 122 missing = builder.check_dependencies(distribution) 123 if missing: 124 dependencies = ''.join('\n\t' + dep for deps in missing for dep in (deps[0], _format_dep_chain(deps[1:])) if dep) 125 print() 126 _error(f'Missing dependencies:{dependencies}') 127 128 return builder.build(distribution, outdir, config_settings or {}) 129 130 131def _build( 132 isolation: bool, 133 builder: ProjectBuilder, 134 outdir: str, 135 distribution: str, 136 config_settings: Optional[ConfigSettingsType], 137 skip_dependency_check: bool, 138) -> str: 139 if isolation: 140 return _build_in_isolated_env(builder, outdir, distribution, config_settings) 141 else: 142 return _build_in_current_env(builder, outdir, distribution, config_settings, skip_dependency_check) 143 144 145@contextlib.contextmanager 146def _handle_build_error() -> Iterator[None]: 147 try: 148 yield 149 except BuildException as e: 150 _error(str(e)) 151 except BuildBackendException as e: 152 if isinstance(e.exception, subprocess.CalledProcessError): 153 print() 154 else: 155 if e.exc_info: 156 tb_lines = traceback.format_exception( 157 e.exc_info[0], 158 e.exc_info[1], 159 e.exc_info[2], 160 limit=-1, 161 ) 162 tb = ''.join(tb_lines) 163 else: 164 tb = traceback.format_exc(-1) 165 print('\n{dim}{}{reset}\n'.format(tb.strip('\n'), **_STYLES)) 166 _error(str(e)) 167 168 169def _natural_language_list(elements: Sequence[str]) -> str: 170 if len(elements) == 0: 171 raise IndexError('no elements') 172 elif len(elements) == 1: 173 return elements[0] 174 else: 175 return '{} and {}'.format( 176 ', '.join(elements[:-1]), 177 elements[-1], 178 ) 179 180 181def build_package( 182 srcdir: str, 183 outdir: str, 184 distributions: Sequence[str], 185 config_settings: Optional[ConfigSettingsType] = None, 186 isolation: bool = True, 187 skip_dependency_check: bool = False, 188) -> Sequence[str]: 189 """ 190 Run the build process. 191 192 :param srcdir: Source directory 193 :param outdir: Output directory 194 :param distribution: Distribution to build (sdist or wheel) 195 :param config_settings: Configuration settings to be passed to the backend 196 :param isolation: Isolate the build in a separate environment 197 :param skip_dependency_check: Do not perform the dependency check 198 """ 199 built: List[str] = [] 200 builder = _ProjectBuilder(srcdir) 201 for distribution in distributions: 202 out = _build(isolation, builder, outdir, distribution, config_settings, skip_dependency_check) 203 built.append(os.path.basename(out)) 204 return built 205 206 207def build_package_via_sdist( 208 srcdir: str, 209 outdir: str, 210 distributions: Sequence[str], 211 config_settings: Optional[ConfigSettingsType] = None, 212 isolation: bool = True, 213 skip_dependency_check: bool = False, 214) -> Sequence[str]: 215 """ 216 Build a sdist and then the specified distributions from it. 217 218 :param srcdir: Source directory 219 :param outdir: Output directory 220 :param distribution: Distribution to build (only wheel) 221 :param config_settings: Configuration settings to be passed to the backend 222 :param isolation: Isolate the build in a separate environment 223 :param skip_dependency_check: Do not perform the dependency check 224 """ 225 if 'sdist' in distributions: 226 raise ValueError('Only binary distributions are allowed but sdist was specified') 227 228 builder = _ProjectBuilder(srcdir) 229 sdist = _build(isolation, builder, outdir, 'sdist', config_settings, skip_dependency_check) 230 231 sdist_name = os.path.basename(sdist) 232 sdist_out = tempfile.mkdtemp(prefix='build-via-sdist-') 233 built: List[str] = [] 234 # extract sdist 235 with tarfile.open(sdist) as t: 236 t.extractall(sdist_out) 237 try: 238 builder = _ProjectBuilder(os.path.join(sdist_out, sdist_name[: -len('.tar.gz')])) 239 if distributions: 240 builder.log(f'Building {_natural_language_list(distributions)} from sdist') 241 for distribution in distributions: 242 out = _build(isolation, builder, outdir, distribution, config_settings, skip_dependency_check) 243 built.append(os.path.basename(out)) 244 finally: 245 shutil.rmtree(sdist_out, ignore_errors=True) 246 return [sdist_name] + built 247 248 249def main_parser() -> argparse.ArgumentParser: 250 """ 251 Construct the main parser. 252 """ 253 # mypy does not recognize module.__path__ 254 # https://github.com/python/mypy/issues/1422 255 paths: Iterable[str] = build.__path__ # type: ignore 256 parser = argparse.ArgumentParser( 257 description=textwrap.indent( 258 textwrap.dedent( 259 ''' 260 A simple, correct PEP 517 package builder. 261 262 By default, a source distribution (sdist) is built from {srcdir} 263 and a binary distribution (wheel) is built from the sdist. 264 This is recommended as it will ensure the sdist can be used 265 to build wheels. 266 267 Pass -s/--sdist and/or -w/--wheel to build a specific distribution. 268 If you do this, the default behavior will be disabled, and all 269 artifacts will be built from {srcdir} (even if you combine 270 -w/--wheel with -s/--sdist, the wheel will be built from {srcdir}). 271 ''' 272 ).strip(), 273 ' ', 274 ), 275 formatter_class=argparse.RawTextHelpFormatter, 276 ) 277 parser.add_argument( 278 'srcdir', 279 type=str, 280 nargs='?', 281 default=os.getcwd(), 282 help='source directory (defaults to current directory)', 283 ) 284 parser.add_argument( 285 '--version', 286 '-V', 287 action='version', 288 version=f"build {build.__version__} ({','.join(paths)})", 289 ) 290 parser.add_argument( 291 '--sdist', 292 '-s', 293 action='store_true', 294 help='build a source distribution (disables the default behavior)', 295 ) 296 parser.add_argument( 297 '--wheel', 298 '-w', 299 action='store_true', 300 help='build a wheel (disables the default behavior)', 301 ) 302 parser.add_argument( 303 '--outdir', 304 '-o', 305 type=str, 306 help=f'output directory (defaults to {{srcdir}}{os.sep}dist)', 307 ) 308 parser.add_argument( 309 '--skip-dependency-check', 310 '-x', 311 action='store_true', 312 help='do not check that build dependencies are installed', 313 ) 314 parser.add_argument( 315 '--no-isolation', 316 '-n', 317 action='store_true', 318 help='do not isolate the build in a virtual environment', 319 ) 320 parser.add_argument( 321 '--config-setting', 322 '-C', 323 action='append', 324 help='pass options to the backend. options which begin with a hyphen must be in the form of ' 325 '"--config-setting=--opt(=value)" or "-C--opt(=value)"', 326 ) 327 return parser 328 329 330def main(cli_args: Sequence[str], prog: Optional[str] = None) -> None: # noqa: C901 331 """ 332 Parse the CLI arguments and invoke the build process. 333 334 :param cli_args: CLI arguments 335 :param prog: Program name to show in help text 336 """ 337 _setup_cli() 338 parser = main_parser() 339 if prog: 340 parser.prog = prog 341 args = parser.parse_args(cli_args) 342 343 distributions = [] 344 config_settings = {} 345 346 if args.config_setting: 347 for arg in args.config_setting: 348 setting, _, value = arg.partition('=') 349 if setting not in config_settings: 350 config_settings[setting] = value 351 else: 352 if not isinstance(config_settings[setting], list): 353 config_settings[setting] = [config_settings[setting]] 354 355 config_settings[setting].append(value) 356 357 if args.sdist: 358 distributions.append('sdist') 359 if args.wheel: 360 distributions.append('wheel') 361 362 # outdir is relative to srcdir only if omitted. 363 outdir = os.path.join(args.srcdir, 'dist') if args.outdir is None else args.outdir 364 365 if distributions: 366 build_call = build_package 367 else: 368 build_call = build_package_via_sdist 369 distributions = ['wheel'] 370 try: 371 with _handle_build_error(): 372 built = build_call( 373 args.srcdir, outdir, distributions, config_settings, not args.no_isolation, args.skip_dependency_check 374 ) 375 artifact_list = _natural_language_list( 376 ['{underline}{}{reset}{bold}{green}'.format(artifact, **_STYLES) for artifact in built] 377 ) 378 print('{bold}{green}Successfully built {}{reset}'.format(artifact_list, **_STYLES)) 379 except Exception as e: # pragma: no cover 380 tb = traceback.format_exc().strip('\n') 381 print('\n{dim}{}{reset}\n'.format(tb, **_STYLES)) 382 _error(str(e)) 383 384 385def entrypoint() -> None: 386 main(sys.argv[1:]) 387 388 389if __name__ == '__main__': # pragma: no cover 390 main(sys.argv[1:], 'python -m build') 391