Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

main: improve console output #749

Merged
merged 14 commits into from
Mar 5, 2024
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -114,6 +114,7 @@ xfail_strict = true
junit_family = "xunit2"
norecursedirs = "tests/integration/*"
markers = [
"contextvars",
"isolated",
"pypy3323bug",
"network",
115 changes: 68 additions & 47 deletions src/build/__main__.py
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@

import argparse
import contextlib
import contextvars
import os
import platform
import shutil
@@ -20,7 +21,7 @@

import build

from . import ProjectBuilder
from . import ProjectBuilder, _ctx
from ._exceptions import BuildBackendException, BuildException, FailedProcessError
from ._types import ConfigSettings, Distribution, StrPath
from .env import DefaultIsolatedEnv
@@ -38,21 +39,21 @@
_NO_COLORS = {color: '' for color in _COLORS}


def _init_colors() -> dict[str, str]:
_styles = contextvars.ContextVar('_styles', default=_COLORS)


def _init_colors() -> None:
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', stacklevel=2)
return _NO_COLORS
_styles.set(_NO_COLORS)
elif 'FORCE_COLOR' in os.environ or sys.stdout.isatty():
return _COLORS
return _NO_COLORS

return
_styles.set(_NO_COLORS)

_STYLES = _init_colors()


def _cprint(fmt: str = '', msg: str = '') -> None:
print(fmt.format(msg, **_STYLES), flush=True)
def _cprint(fmt: str = '', msg: str = '', file: TextIO | None = None) -> None:
print(fmt.format(msg, **_styles.get()), file=file, flush=True)


def _showwarning(
@@ -66,7 +67,28 @@ def _showwarning(
_cprint('{yellow}WARNING{reset} {}', str(message))


def _setup_cli() -> None:
_max_terminal_width = shutil.get_terminal_size().columns - 2


_fill = partial(textwrap.fill, subsequent_indent=' ', width=_max_terminal_width)


def _log(message: str, *, origin: tuple[str, ...] | None = None) -> None:
if origin is None:
(first, *rest) = message.splitlines()
_cprint('{bold}{}{reset}', _fill(first, initial_indent='* '))
for line in rest:
print(_fill(line, initial_indent=' '))

elif origin[0] == 'subprocess':
initial_indent = '> ' if origin[1] == 'cmd' else '< '
color = '{red}' if origin[1] == 'stderr' else '{dim}'
file = sys.stderr if origin[1] == 'stderr' else None
for line in message.splitlines():
_cprint(color + '{}{reset}', _fill(line, initial_indent=initial_indent), file=file)


def _setup_cli(*, verbosity: int) -> None:
warnings.showwarning = _showwarning

if platform.system() == 'Windows':
@@ -77,6 +99,11 @@ def _setup_cli() -> None:
except ModuleNotFoundError:
pass

_init_colors()

_ctx.LOGGER.set(_log)
_ctx.VERBOSITY.set(verbosity)


def _error(msg: str, code: int = 1) -> NoReturn: # pragma: no cover
"""
@@ -89,18 +116,6 @@ def _error(msg: str, code: int = 1) -> NoReturn: # pragma: no cover
raise SystemExit(code)


class _ProjectBuilder(ProjectBuilder):
@staticmethod
def log(message: str) -> None:
_cprint('{bold}* {}{reset}', message)


class _DefaultIsolatedEnv(DefaultIsolatedEnv):
@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)

@@ -111,8 +126,8 @@ def _build_in_isolated_env(
distribution: Distribution,
config_settings: ConfigSettings | None,
) -> str:
with _DefaultIsolatedEnv() as env:
builder = _ProjectBuilder.from_isolated_env(env, srcdir)
with DefaultIsolatedEnv() as env:
builder = ProjectBuilder.from_isolated_env(env, srcdir)
# 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)
@@ -127,7 +142,7 @@ def _build_in_current_env(
config_settings: ConfigSettings | None,
skip_dependency_check: bool = False,
) -> str:
builder = _ProjectBuilder(srcdir)
builder = ProjectBuilder(srcdir)

if not skip_dependency_check:
missing = builder.check_dependencies(distribution, config_settings or {})
@@ -176,6 +191,10 @@ def _handle_build_error() -> Iterator[None]:
tb = traceback.format_exc(-1)
_cprint('\n{dim}{}{reset}\n', tb.strip('\n'))
_error(str(e))
except Exception as e: # pragma: no cover
tb = traceback.format_exc().strip('\n')
_cprint('\n{dim}{}{reset}\n', tb)
_error(str(e))


def _natural_language_list(elements: Sequence[str]) -> str:
@@ -250,7 +269,7 @@ def build_package_via_sdist(
with tarfile.TarFile.open(sdist) as t:
t.extractall(sdist_out)
try:
_ProjectBuilder.log(f'Building {_natural_language_list(distributions)} from sdist')
_ctx.log(f'Building {_natural_language_list(distributions)} from sdist')
srcdir = os.path.join(sdist_out, sdist_name[: -len('.tar.gz')])
for distribution in distributions:
out = _build(isolation, srcdir, outdir, distribution, config_settings, skip_dependency_check)
@@ -283,12 +302,9 @@ def main_parser() -> argparse.ArgumentParser:
).strip(),
' ',
),
formatter_class=partial(
argparse.RawDescriptionHelpFormatter,
# Prevent argparse from taking up the entire width of the terminal window
# which impedes readability.
width=min(shutil.get_terminal_size().columns - 2, 127),
),
# Prevent argparse from taking up the entire width of the terminal window
# which impedes readability.
formatter_class=partial(argparse.RawDescriptionHelpFormatter, width=min(_max_terminal_width, 127)),
)
parser.add_argument(
'srcdir',
@@ -303,6 +319,14 @@ def main_parser() -> argparse.ArgumentParser:
action='version',
version=f"build {build.__version__} ({','.join(build.__path__)})",
)
parser.add_argument(
'--verbose',
'-v',
dest='verbosity',
action='count',
default=0,
help='increase verbosity',
)
parser.add_argument(
'--sdist',
'-s',
@@ -354,12 +378,13 @@ def main(cli_args: Sequence[str], prog: str | None = None) -> None:
: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)

_setup_cli(verbosity=args.verbosity)

distributions: list[Distribution] = []
config_settings = {}

@@ -387,19 +412,15 @@ def main(cli_args: Sequence[str], prog: str | None = None) -> None:
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))

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.get()) for artifact in built]
)
_cprint('{bold}{green}Successfully built {}{reset}', artifact_list)


def entrypoint() -> None:
24 changes: 4 additions & 20 deletions src/build/_builder.py
Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@

import contextlib
import difflib
import logging
import os
import subprocess
import sys
@@ -16,7 +15,7 @@

import pyproject_hooks

from . import env
from . import _ctx, env
from ._compat import tomllib
from ._exceptions import (
BuildBackendException,
@@ -37,9 +36,6 @@
}


_logger = logging.getLogger(__name__)


def _find_typo(dictionary: Mapping[str, str], expected: str) -> None:
for obj in dictionary:
if difflib.SequenceMatcher(None, expected, obj).ratio() >= 0.8:
@@ -216,7 +212,7 @@ def get_requires_for_build(self, distribution: Distribution, config_settings: Co
(``sdist`` or ``wheel``)
:param config_settings: Config settings for the build backend
"""
self.log(f'Getting build dependencies for {distribution}...')
_ctx.log(f'Getting build dependencies for {distribution}...')
hook_name = f'get_requires_for_build_{distribution}'
get_requires = getattr(self._hook, hook_name)

@@ -252,7 +248,7 @@ def prepare(
:param config_settings: Config settings for the build backend
:returns: The full path to the prepared metadata directory
"""
self.log(f'Getting metadata for {distribution}...')
_ctx.log(f'Getting metadata for {distribution}...')
try:
return self._call_backend(
f'prepare_metadata_for_build_{distribution}',
@@ -282,7 +278,7 @@ def build(
previous ``prepare`` call on the same ``distribution`` kind
:returns: The full path to the built distribution
"""
self.log(f'Building {distribution}...')
_ctx.log(f'Building {distribution}...')
kwargs = {} if metadata_directory is None else {'metadata_directory': metadata_directory}
return self._call_backend(f'build_{distribution}', output_directory, config_settings, **kwargs)

@@ -349,15 +345,3 @@ def _handle_backend(self, hook: str) -> Iterator[None]:
raise BuildBackendException(exception, f'Backend subprocess exited when trying to invoke {hook}') from None
except Exception as exception:
raise BuildBackendException(exception, exc_info=sys.exc_info()) from None

@staticmethod
def log(message: str) -> None:
"""
Log a message.

The default implementation uses the logging module but this function can be
overridden by users to have a different implementation.

:param message: Message to output
"""
_logger.log(logging.INFO, message, stacklevel=2)
Loading
Loading