Skip to content

ENH: remove fallback needed only with now unsupported Meson versions #280

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

Merged
merged 7 commits into from
Mar 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 76 additions & 148 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@
'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


class _depstr:
Expand Down Expand Up @@ -126,6 +128,46 @@ def _init_colors() -> Dict[str, str]:
assert all(re.match(_EXTENSION_SUFFIX_REGEX, x) for x in _EXTENSION_SUFFIXES)


# Maps wheel installation paths to Meson installation path placeholders.
# See https://docs.python.org/3/library/sysconfig.html#installation-paths
_SCHEME_MAP = {
'scripts': ('{bindir}',),
'purelib': ('{py_purelib}',),
'platlib': ('{py_platlib}', '{moduledir_shared}'),
'headers': ('{includedir}',),
'data': ('{datadir}',),
# our custom location
'mesonpy-libs': ('{libdir}', '{libdir_shared}')
}


def _map_meson_destination(destination: str) -> Tuple[Optional[str], pathlib.Path]:
"""Map a Meson installation path to a wheel installation location.

Return a (wheel path identifier, subpath inside the wheel path) tuple.

"""
parts = pathlib.Path(destination).parts
for folder, placeholders in _SCHEME_MAP.items():
if parts[0] in placeholders:
return folder, pathlib.Path(*parts[1:])
warnings.warn(f'Could not map installation path to an equivalent wheel directory: {destination!r}')
if not re.match(r'^{\w+}$', parts[0]):
raise RuntimeError('Meson installation path {destination!r} does not start with a placeholder. Meson bug!')
return None, pathlib.Path(destination)


def _map_to_wheel(sources: Dict[str, Dict[str, Any]]) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]:
"""Map files to the wheel, organized by wheel installation directrory."""
wheel_files = collections.defaultdict(list)
for group in sources.values():
for src, target in group.items():
directory, path = _map_meson_destination(target['destination'])
if directory is not None:
wheel_files[directory].append((path, src))
return wheel_files


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


def _setup_cli() -> None:
Expand Down Expand Up @@ -178,40 +220,25 @@ class MesonBuilderError(Error):
class _WheelBuilder():
"""Helper class to build wheels from projects."""

# Maps wheel scheme names to Meson placeholder directories
_SCHEME_MAP: ClassVar[Dict[str, Tuple[str, ...]]] = {
'scripts': ('{bindir}',),
'purelib': ('{py_purelib}',),
'platlib': ('{py_platlib}', '{moduledir_shared}'),
'headers': ('{includedir}',),
'data': ('{datadir}',),
# our custom location
'mesonpy-libs': ('{libdir}', '{libdir_shared}')
}

def __init__(
self,
project: Project,
metadata: Optional[pyproject_metadata.StandardMetadata],
source_dir: pathlib.Path,
install_dir: pathlib.Path,
build_dir: pathlib.Path,
sources: Dict[str, Dict[str, Any]],
copy_files: Dict[str, str],
) -> None:
self._project = project
self._metadata = metadata
self._source_dir = source_dir
self._install_dir = install_dir
self._build_dir = build_dir
self._sources = sources
self._copy_files = copy_files

self._libs_build_dir = self._build_dir / 'mesonpy-wheel-libs'

@cached_property
def _wheel_files(self) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]:
return self._map_to_wheel(self._sources, self._copy_files)
return _map_to_wheel(self._sources)

@property
def _has_internal_libs(self) -> bool:
Expand Down Expand Up @@ -394,106 +421,6 @@ def _is_native(self, file: Union[str, pathlib.Path]) -> bool:
return True
return False

def _warn_unsure_platlib(self, origin: pathlib.Path, destination: pathlib.Path) -> None:
"""Warn if we are unsure if the file should be mapped to purelib or platlib.

This happens when we use heuristics to try to map a file purelib or
platlib but can't differentiate between the two. In which case, we place
the file in platlib to be safe and warn the user.

If we can detect the file is architecture dependent and indeed does not
belong in purelib, we will skip the warning.
"""
# {moduledir_shared} is currently handled in heuristics due to a Meson bug,
# but we know that files that go there are supposed to go to platlib.
if self._is_native(origin):
# The file is architecture dependent and does not belong in puredir,
# so the warning is skipped.
return
warnings.warn(
'Could not tell if file was meant for purelib or platlib, '
f'so it was mapped to platlib: {origin} ({destination})',
stacklevel=2,
)

def _map_from_heuristics(self, origin: pathlib.Path, destination: pathlib.Path) -> Optional[Tuple[str, pathlib.Path]]:
"""Extracts scheme and relative destination with heuristics based on the
origin file and the Meson destination path.
"""
warnings.warn('Using heuristics to map files to wheel, this may result in incorrect locations')
sys_paths = mesonpy._introspection.SYSCONFIG_PATHS
# Try to map to Debian dist-packages
if mesonpy._introspection.DEBIAN_PYTHON:
search_path = origin
while search_path != search_path.parent:
search_path = search_path.parent
if search_path.name == 'dist-packages' and search_path.parent.parent.name == 'lib':
calculated_path = origin.relative_to(search_path)
warnings.warn(f'File matched Debian heuristic ({calculated_path}): {origin} ({destination})')
self._warn_unsure_platlib(origin, destination)
return 'platlib', calculated_path
# Try to map to the interpreter purelib or platlib
for scheme in ('purelib', 'platlib'):
# try to match the install path on the system to one of the known schemes
scheme_path = pathlib.Path(sys_paths[scheme]).absolute()
destdir_scheme_path = self._install_dir / scheme_path.relative_to(scheme_path.anchor)
try:
wheel_path = pathlib.Path(origin).relative_to(destdir_scheme_path)
except ValueError:
continue
if sys_paths['purelib'] == sys_paths['platlib']:
self._warn_unsure_platlib(origin, destination)
return 'platlib', wheel_path
return None # no match was found

def _map_from_scheme_map(self, destination: str) -> Optional[Tuple[str, pathlib.Path]]:
"""Extracts scheme and relative destination from Meson paths.

Meson destination path -> (wheel scheme, subpath inside the scheme)
Eg. {bindir}/foo/bar -> (scripts, foo/bar)
"""
for scheme, placeholder in [
(scheme, placeholder)
for scheme, placeholders in self._SCHEME_MAP.items()
for placeholder in placeholders
]: # scheme name, scheme path (see self._SCHEME_MAP)
if destination.startswith(placeholder):
relative_destination = pathlib.Path(destination).relative_to(placeholder)
return scheme, relative_destination
return None # no match was found

def _map_to_wheel(
self,
sources: Dict[str, Dict[str, Any]],
copy_files: Dict[str, str],
) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]:
"""Map files to the wheel, organized by scheme."""
wheel_files = collections.defaultdict(list)
for files in sources.values(): # entries in intro-install_plan.json
for file, details in files.items(): # install path -> {destination, tag}
# try mapping to wheel location
meson_destination = details['destination']
install_details = (
# using scheme map
self._map_from_scheme_map(meson_destination)
# using heuristics
or self._map_from_heuristics(
pathlib.Path(copy_files[file]),
pathlib.Path(meson_destination),
)
)
if install_details:
scheme, destination = install_details
wheel_files[scheme].append((destination, file))
continue
# not found
warnings.warn(
'File could not be mapped to an equivalent wheel directory: '
'{} ({})'.format(copy_files[file], meson_destination)
)

return wheel_files

def _install_path(
self,
wheel_file: mesonpy._wheelfile.WheelFile,
Expand Down Expand Up @@ -588,7 +515,7 @@ def build(self, directory: Path) -> pathlib.Path:
self._install_path(whl, counter, origin, destination)

# install the other schemes
for scheme in self._SCHEME_MAP:
for scheme in _SCHEME_MAP:
if scheme in (root_scheme, 'mesonpy-libs'):
continue
for destination, origin in self._wheel_files[scheme]:
Expand Down Expand Up @@ -646,7 +573,7 @@ def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path
)

# install non-code schemes
for scheme in self._SCHEME_MAP:
for scheme in _SCHEME_MAP:
if scheme in ('purelib', 'platlib', 'mesonpy-libs'):
continue
for destination, origin in self._wheel_files[scheme]:
Expand Down Expand Up @@ -750,7 +677,8 @@ def __init__(
self._meson_args: MesonArgs = collections.defaultdict(list)
self._env = os.environ.copy()

# prepare environment
_check_meson_version()

self._ninja = _env_ninja_command()
if self._ninja is None:
raise ConfigError(f'Could not find ninja version {_NINJA_REQUIRED_VERSION} or newer.')
Expand Down Expand Up @@ -896,10 +824,8 @@ def _wheel_builder(self) -> _WheelBuilder:
self,
self._metadata,
self._source_dir,
self._install_dir,
self._build_dir,
self._install_plan,
self._copy_files,
)

def build_commands(self, install_dir: Optional[pathlib.Path] = None) -> Sequence[Sequence[str]]:
Expand Down Expand Up @@ -967,17 +893,6 @@ def _install_plan(self) -> Dict[str, Dict[str, Dict[str, str]]]:

return install_plan

@property
def _copy_files(self) -> Dict[str, str]:
"""Files that Meson will copy on install and the target location."""
copy_files = {}
for origin, destination in self._info('intro-installed').items():
destination_path = pathlib.Path(destination).absolute()
copy_files[origin] = os.fspath(
self._install_dir / destination_path.relative_to(destination_path.anchor)
)
return copy_files

@property
def _meson_name(self) -> str:
"""Name in meson.build."""
Expand Down Expand Up @@ -1107,29 +1022,42 @@ def _project(config_settings: Optional[Dict[Any, Any]]) -> Iterator[Project]:
yield project


def _parse_version_string(string: str) -> Tuple[int, ...]:
"""Parse version string."""
try:
return tuple(map(int, string.split('.')[:3]))
except ValueError:
return (0, )


def _env_ninja_command(*, version: str = _NINJA_REQUIRED_VERSION) -> Optional[str]:
"""
Returns the path to ninja, or None if no ninja found.
"""
required_version = tuple(int(v) for v in version.split('.'))
"""Returns the path to ninja, or None if no ninja found."""
required_version = _parse_version_string(version)
env_ninja = os.environ.get('NINJA')
ninja_candidates = [env_ninja] if env_ninja else ['ninja', 'ninja-build', 'samu']
for ninja in ninja_candidates:
ninja_path = shutil.which(ninja)
if ninja_path is None:
continue
if ninja_path is not None:
version = subprocess.run([ninja_path, '--version'], check=False, text=True, capture_output=True).stdout
if _parse_version_string(version) >= required_version:
return ninja_path
return None

result = subprocess.run([ninja_path, '--version'], check=False, text=True, capture_output=True)

try:
candidate_version = tuple(int(x) for x in result.stdout.split('.')[:3])
except ValueError:
continue
if candidate_version < required_version:
continue
return ninja_path
def _check_meson_version(*, version: str = _MESON_REQUIRED_VERSION) -> None:
"""Check that the meson executable in the path has an appropriate version.

return None
The meson Python package is a dependency of the meson-python
Python package, however, it may occur that the meson Python
package is installed but the corresponding meson command is not
available in $PATH. Implement a runtime check to verify that the
build environment is setup correcly.

"""
required_version = _parse_version_string(version)
meson_version = subprocess.run(['meson', '--version'], check=False, text=True, capture_output=True).stdout
if _parse_version_string(meson_version) < required_version:
raise ConfigError(f'Could not find meson version {version} or newer, found {meson_version}.')


def _pyproject_hook(func: Callable[P, T]) -> Callable[P, T]:
Expand Down
46 changes: 0 additions & 46 deletions mesonpy/_introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,67 +7,21 @@
import sys
import sysconfig
import typing
import warnings


if typing.TYPE_CHECKING: # pragma: no cover
from mesonpy._compat import Mapping


def debian_python() -> bool:
"""Check if we are running on Debian-patched Python."""
if sys.version_info >= (3, 10):
return 'deb_system' in sysconfig.get_scheme_names()
try:
import distutils
try:
import distutils.command.install
except ModuleNotFoundError as exc:
raise ModuleNotFoundError('Unable to import distutils, please install python3-distutils') from exc
return 'deb_system' in distutils.command.install.INSTALL_SCHEMES
except ModuleNotFoundError:
return False


DEBIAN_PYTHON = debian_python()


def debian_distutils_paths() -> Mapping[str, str]:
# https://ffy00.github.io/blog/02-python-debian-and-the-install-locations/
assert sys.version_info < (3, 12) and DEBIAN_PYTHON

with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=DeprecationWarning)
import distutils.dist

distribution = distutils.dist.Distribution()
install_cmd = distribution.get_command_obj('install')
install_cmd.install_layout = 'deb' # type: ignore[union-attr]
install_cmd.finalize_options() # type: ignore[union-attr]

return {
'data': install_cmd.install_data, # type: ignore[union-attr]
'platlib': install_cmd.install_platlib, # type: ignore[union-attr]
'purelib': install_cmd.install_purelib, # type: ignore[union-attr]
'scripts': install_cmd.install_scripts, # type: ignore[union-attr]
}


def sysconfig_paths() -> Mapping[str, str]:
sys_vars = sysconfig.get_config_vars().copy()
sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix
if DEBIAN_PYTHON:
if sys.version_info >= (3, 10, 3):
return sysconfig.get_paths('deb_system', vars=sys_vars)
else:
return debian_distutils_paths()
return sysconfig.get_paths(vars=sys_vars)


SYSCONFIG_PATHS = sysconfig_paths()


__all__ = [
'DEBIAN_PYTHON',
'SYSCONFIG_PATHS',
]
Loading