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