Skip to content

Commit

Permalink
MAINT: simplify logging and use of ANSI color escapes
Browse files Browse the repository at this point in the history
Moved code around to have constants at the top of the module and to
group similar functions together.
  • Loading branch information
dnicolodi authored and rgommers committed Sep 13, 2023
1 parent 1e79b26 commit 36e8205
Show file tree
Hide file tree
Showing 2 changed files with 78 additions and 122 deletions.
166 changes: 71 additions & 95 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@
from mesonpy._compat import cached_property, read_binary


_MESON_ARGS_KEYS = ['dist', 'setup', 'compile', 'install']

if typing.TYPE_CHECKING: # pragma: no cover
from typing import Any, Callable, DefaultDict, Dict, List, Literal, Optional, Sequence, TextIO, Tuple, Type, TypeVar, Union

Expand All @@ -69,49 +67,15 @@
__version__ = '0.15.0.dev0'


_COLORS = {
'red': '\33[31m',
'cyan': '\33[36m',
'yellow': '\33[93m',
'light_blue': '\33[94m',
'bold': '\33[1m',
'dim': '\33[2m',
'underline': '\33[4m',
'reset': '\33[0m',
}
_NO_COLORS = {color: '' for color in _COLORS}

_NINJA_REQUIRED_VERSION = '1.8.2'
_MESON_REQUIRED_VERSION = '0.63.3' # keep in sync with the version requirement in pyproject.toml


def _init_colors() -> Dict[str, str]:
"""Detect if we should be using colors in the output. We will enable colors
if running in a TTY, and no environment variable overrides it. Setting the
NO_COLOR (https://no-color.org/) environment variable force-disables colors,
and FORCE_COLOR forces color to be used, which is useful for thing like
Github actions.
"""
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=1,
)
return _NO_COLORS
elif 'FORCE_COLOR' in os.environ or sys.stdout.isatty():
return _COLORS
return _NO_COLORS


_STYLES = _init_colors() # holds the color values, should be _COLORS or _NO_COLORS

_MESON_ARGS_KEYS = ['dist', 'setup', 'compile', 'install']

_SUFFIXES = importlib.machinery.all_suffixes()
_EXTENSION_SUFFIX_REGEX = re.compile(r'^[^.]+\.(?:(?P<abi>[^.]+)\.)?(?:so|pyd|dll)$')
assert all(re.match(_EXTENSION_SUFFIX_REGEX, f'foo{x}') for x in importlib.machinery.EXTENSION_SUFFIXES)


# Map Meson installation path placeholders to wheel installation paths.
# See https://docs.python.org/3/library/sysconfig.html#installation-paths
_INSTALLATION_PATH_MAP = {
Expand Down Expand Up @@ -178,29 +142,40 @@ def _map_to_wheel(sources: Dict[str, Dict[str, Any]]) -> DefaultDict[str, List[T
return wheel_files


def _is_native(file: Path) -> bool:
"""Check if file is a native file."""
class style:
ERROR = '\33[31m', # red
WARNING = '\33[93m' # bright yellow
INFO = '\33[36m\33[1m' # cyan, bold
RESET = '\33[0m'

with open(file, 'rb') as f:
if sys.platform == 'linux':
return f.read(4) == b'\x7fELF' # ELF
elif sys.platform == 'darwin':
return f.read(4) in (
b'\xfe\xed\xfa\xce', # 32-bit
b'\xfe\xed\xfa\xcf', # 64-bit
b'\xcf\xfa\xed\xfe', # arm64
b'\xca\xfe\xba\xbe', # universal / fat (same as java class so beware!)
)
elif sys.platform == 'win32':
return f.read(2) == b'MZ'
@staticmethod
def strip(string: str) -> str:
"""Strip ANSI escape sequences from string."""
return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', string)

# For unknown platforms, check for file extensions.
_, ext = os.path.splitext(file)
if ext in ('.so', '.a', '.out', '.exe', '.dll', '.dylib', '.pyd'):

@functools.lru_cache()
def _use_ansi_colors() -> bool:
"""Determine whether logging should use ANSI color escapes."""
if 'NO_COLOR' in os.environ:
return False
if 'FORCE_COLOR' in os.environ or sys.stdout.isatty() and os.environ.get('TERM') != 'dumb':
try:
import colorama
except ModuleNotFoundError:
pass
else:
colorama.init()
return True
return False


def _log(string: str , **kwargs: Any) -> None:
if not _use_ansi_colors():
string = style.strip(string)
print(string, **kwargs)


def _showwarning(
message: Union[Warning, str],
category: Type[Warning],
Expand All @@ -210,21 +185,7 @@ def _showwarning(
line: Optional[str] = None,
) -> None: # pragma: no cover
"""Callable to override the default warning handler, to have colored output."""
print('{yellow}meson-python: warning:{reset} {}'.format(message, **_STYLES))


def _setup_cli() -> None:
"""Setup CLI stuff (eg. handlers, hooks, etc.). Should only be called when
actually we are in control of the CLI, not on a normal import.
"""
warnings.showwarning = _showwarning

try: # pragma: no cover
import colorama
except ModuleNotFoundError: # pragma: no cover
pass
else: # pragma: no cover
colorama.init() # fix colors on windows
_log(f'{style.WARNING}meson-python: warning:{style.RESET} {message}')


class Error(RuntimeError):
Expand Down Expand Up @@ -273,6 +234,27 @@ def _update_dynamic(self, value: Any) -> None:
self.dynamic.remove('version')


def _is_native(file: Path) -> bool:
"""Check if file is a native file."""

with open(file, 'rb') as f:
if sys.platform == 'linux':
return f.read(4) == b'\x7fELF' # ELF
elif sys.platform == 'darwin':
return f.read(4) in (
b'\xfe\xed\xfa\xce', # 32-bit
b'\xfe\xed\xfa\xcf', # 64-bit
b'\xcf\xfa\xed\xfe', # arm64
b'\xca\xfe\xba\xbe', # universal / fat (same as java class so beware!)
)
elif sys.platform == 'win32':
return f.read(2) == b'MZ'

# For unknown platforms, check for file extensions.
_, ext = os.path.splitext(file)
return ext in ('.so', '.a', '.out', '.exe', '.dll', '.dylib', '.pyd')


class _WheelBuilder():
"""Helper class to build wheels from projects."""

Expand Down Expand Up @@ -729,7 +711,7 @@ def _run(self, cmd: Sequence[str]) -> None:
# Flush the line to ensure that the log line with the executed
# command line appears before the command output. Without it,
# the lines appear in the wrong order in pip output.
print('{cyan}{bold}+ {}{reset}'.format(' '.join(cmd), **_STYLES), flush=True)
_log('{style.INFO}+ {cmd}{style.RESET}'.format(style=style, cmd=' '.join(cmd)), flush=True)
r = subprocess.run(cmd, cwd=self._build_dir)
if r.returncode != 0:
raise SystemExit(r.returncode)
Expand Down Expand Up @@ -991,11 +973,12 @@ def _add_ignore_files(directory: pathlib.Path) -> None:
def _pyproject_hook(func: Callable[P, T]) -> Callable[P, T]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
warnings.showwarning = _showwarning
try:
return func(*args, **kwargs)
except (Error, pyproject_metadata.ConfigurationError) as exc:
prefix = '{red}meson-python: error:{reset} '.format(**_STYLES)
print('\n' + textwrap.indent(str(exc), prefix))
prefix = f'{style.ERROR}meson-python: error:{style.RESET} '
_log('\n' + textwrap.indent(str(exc), prefix))
raise SystemExit(1) from exc
return wrapper

Expand All @@ -1010,18 +993,6 @@ def get_requires_for_build_sdist(config_settings: Optional[Dict[str, str]] = Non
return dependencies


@_pyproject_hook
def build_sdist(
sdist_directory: str,
config_settings: Optional[Dict[Any, Any]] = None,
) -> str:
_setup_cli()

out = pathlib.Path(sdist_directory)
with _project(config_settings) as project:
return project.sdist(out).name


@_pyproject_hook
def get_requires_for_build_wheel(config_settings: Optional[Dict[str, str]] = None) -> List[str]:
dependencies = []
Expand All @@ -1035,13 +1006,26 @@ def get_requires_for_build_wheel(config_settings: Optional[Dict[str, str]] = Non
return dependencies


get_requires_for_build_editable = get_requires_for_build_wheel


@_pyproject_hook
def build_wheel(
wheel_directory: str,
def build_sdist(
sdist_directory: str,
config_settings: Optional[Dict[Any, Any]] = None,
) -> str:

out = pathlib.Path(sdist_directory)
with _project(config_settings) as project:
return project.sdist(out).name


@_pyproject_hook
def build_wheel(
wheel_directory: str, config_settings:
Optional[Dict[Any, Any]] = None,
metadata_directory: Optional[str] = None,
) -> str:
_setup_cli()

out = pathlib.Path(wheel_directory)
with _project(config_settings) as project:
Expand All @@ -1054,7 +1038,6 @@ def build_editable(
config_settings: Optional[Dict[Any, Any]] = None,
metadata_directory: Optional[str] = None,
) -> str:
_setup_cli()

# Force set a permanent build directory.
if not config_settings:
Expand All @@ -1069,10 +1052,3 @@ def build_editable(
out = pathlib.Path(wheel_directory)
with _project(config_settings) as project:
return project.editable(out).name


@_pyproject_hook
def get_requires_for_build_editable(
config_settings: Optional[Dict[str, str]] = None,
) -> List[str]:
return get_requires_for_build_wheel()
34 changes: 7 additions & 27 deletions tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,11 @@
#
# SPDX-License-Identifier: MIT

import importlib

import pytest

import mesonpy


@pytest.fixture()
def reload_module():
try:
yield
finally:
importlib.reload(mesonpy)


@pytest.mark.parametrize(
('tty', 'env', 'colors'),
[
Expand All @@ -26,29 +16,19 @@ def reload_module():
(True, {'NO_COLOR': ''}, False),
(False, {'FORCE_COLOR': ''}, True),
(True, {'FORCE_COLOR': ''}, True),
(True, {'FORCE_COLOR': '', 'NO_COLOR': ''}, False),
(True, {'TERM': ''}, True),
(True, {'TERM': 'dumb'}, False),
],
)
def test_colors(mocker, monkeypatch, reload_module, tty, env, colors):
def test_use_ansi_colors(mocker, monkeypatch, tty, env, colors):
mocker.patch('sys.stdout.isatty', return_value=tty)
monkeypatch.delenv('NO_COLOR', raising=False)
monkeypatch.delenv('FORCE_COLOR', raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)

importlib.reload(mesonpy) # reload module to set _STYLES

assert mesonpy._STYLES == (mesonpy._COLORS if colors else mesonpy._NO_COLORS)


def test_colors_conflict(monkeypatch, reload_module):
with monkeypatch.context() as m:
m.setenv('NO_COLOR', '')
m.setenv('FORCE_COLOR', '')

with pytest.warns(
UserWarning,
match='Both NO_COLOR and FORCE_COLOR environment variables are set, disabling color',
):
importlib.reload(mesonpy)
# Clear caching by functools.lru_cache().
mesonpy._use_ansi_colors.cache_clear()

assert mesonpy._STYLES == mesonpy._NO_COLORS
assert mesonpy._use_ansi_colors() == colors

0 comments on commit 36e8205

Please sign in to comment.