Skip to content
New issue

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

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

Already on GitHub? Sign in 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
Expand Up @@ -114,6 +114,7 @@ xfail_strict = true
junit_family = "xunit2"
norecursedirs = "tests/integration/*"
markers = [
"contextvars",
"isolated",
"pypy3323bug",
"network",
Expand Down
114 changes: 67 additions & 47 deletions src/build/__main__.py
Expand Up @@ -4,6 +4,7 @@

import argparse
import contextlib
import contextvars
import os
import platform
import shutil
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -66,7 +67,27 @@ 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 '< '
file = sys.stderr if origin[1] == 'stderr' else None
for line in message.splitlines():
_cprint('{dim}{}{reset}', _fill(line, initial_indent=initial_indent), file=file)


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

if platform.system() == 'Windows':
Expand All @@ -77,6 +98,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
"""
Expand All @@ -89,18 +115,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)

Expand All @@ -111,8 +125,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)
Expand All @@ -127,7 +141,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 {})
Expand Down Expand Up @@ -176,6 +190,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:
Expand Down Expand Up @@ -250,7 +268,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)
Expand Down Expand Up @@ -283,12 +301,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',
Expand All @@ -303,6 +318,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',
Expand Down Expand Up @@ -354,12 +377,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 = {}

Expand Down Expand Up @@ -387,19 +411,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:
Expand Down
24 changes: 4 additions & 20 deletions src/build/_builder.py
Expand Up @@ -4,7 +4,6 @@

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

import pyproject_hooks

from . import env
from . import _ctx, env
from ._compat import tomllib
from ._exceptions import (
BuildBackendException,
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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}',
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)