Skip to content

MAINT: refactor config parsing and validation code #334

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

Closed
wants to merge 2 commits into from
Closed
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
1 change: 1 addition & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ endif
py.install_sources(
'mesonpy/__init__.py',
'mesonpy/_compat.py',
'mesonpy/_config.py',
'mesonpy/_dylib.py',
'mesonpy/_editable.py',
'mesonpy/_elf.py',
Expand Down
189 changes: 57 additions & 132 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,22 @@
import pyproject_metadata

import mesonpy._compat
import mesonpy._config
import mesonpy._dylib
import mesonpy._elf
import mesonpy._introspection
import mesonpy._tags
import mesonpy._util
import mesonpy._wheelfile

from mesonpy._compat import Collection, Iterable, Mapping, cached_property, read_binary
from mesonpy._compat import Collection, Iterable, cached_property, read_binary
from mesonpy._config import BuildHookSettings, ToolSettings


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

from mesonpy._compat import Iterator, Literal, ParamSpec, Path
from mesonpy._compat import Iterator, ParamSpec, Path

P = ParamSpec('P')
T = TypeVar('T')
Expand All @@ -66,18 +68,6 @@
__version__ = '0.13.0.dev1'


# XXX: Once Python 3.8 is our minimum supported version, get rid of
# meson_args_keys and use typing.get_args(MesonArgsKeys) instead.

# Keep both definitions in sync!
_MESON_ARGS_KEYS = ['dist', 'setup', 'compile', 'install']
if typing.TYPE_CHECKING:
MesonArgsKeys = Literal['dist', 'setup', 'compile', 'install']
MesonArgs = Mapping[MesonArgsKeys, List[str]]
else:
MesonArgs = None


_COLORS = {
'red': '\33[31m',
'cyan': '\33[36m',
Expand Down Expand Up @@ -662,22 +652,25 @@ class Project():
]
_metadata: Optional[pyproject_metadata.StandardMetadata]

def __init__( # noqa: C901
def __init__(
self,
source_dir: Path,
working_dir: Path,
build_dir: Optional[Path] = None,
meson_args: Optional[MesonArgs] = None,
editable_verbose: bool = False,
hook_settings: BuildHookSettings | None = None,
) -> None:
self._hook_settings = hook_settings or BuildHookSettings()
self._source_dir = pathlib.Path(source_dir).absolute()
self._working_dir = pathlib.Path(working_dir).absolute()
self._build_dir = pathlib.Path(build_dir).absolute() if build_dir else (self._working_dir / 'build')
self._editable_verbose = editable_verbose

if self._hook_settings.builddir:
self._build_dir = self._hook_settings.builddir.absolute()
else:
self._build_dir = self._working_dir / 'build'

self._install_dir = self._working_dir / 'install'
self._meson_native_file = self._build_dir / 'meson-python-native-file.ini'
self._meson_cross_file = self._build_dir / 'meson-python-cross-file.ini'
self._meson_args: MesonArgs = collections.defaultdict(list)

self._env = os.environ.copy()

# prepare environment
Expand All @@ -686,6 +679,29 @@ def __init__( # noqa: C901
raise ConfigError(f'Could not find ninja version {_NINJA_REQUIRED_VERSION} or newer.')
self._env.setdefault('NINJA', self._ninja)

# load config -- PEP 621 support is optional
pyproject_data = tomllib.loads(self._source_dir.joinpath('pyproject.toml').read_text())
self._pep621 = 'project' in pyproject_data
if self.pep621:
self._metadata = pyproject_metadata.StandardMetadata.from_pyproject(pyproject_data, self._source_dir)
else:
print(
'{yellow}{bold}! Using Meson to generate the project metadata '
'(no `project` section in pyproject.toml){reset}'.format(**_STYLES)
)
self._metadata = None

if self._metadata:
self._validate_metadata()

# load meson args
self._config = ToolSettings.from_pyproject(pyproject_data)
self._meson_args = self._config.meson_args + self._hook_settings.meson_args

# make sure the build dir exists
self._build_dir.mkdir(exist_ok=True, parents=True)
self._install_dir.mkdir(exist_ok=True, parents=True)

# setuptools-like ARCHFLAGS environment variable support
if sysconfig.get_platform().startswith('macosx-'):
archflags = self._env.get('ARCHFLAGS')
Expand All @@ -710,41 +726,7 @@ def __init__( # noqa: C901
endian = 'little'
''')
self._meson_cross_file.write_text(cross_file_data)
self._meson_args['setup'].extend(('--cross-file', os.fspath(self._meson_cross_file)))

# load config -- PEP 621 support is optional
self._config = tomllib.loads(self._source_dir.joinpath('pyproject.toml').read_text())
self._pep621 = 'project' in self._config
if self.pep621:
self._metadata = pyproject_metadata.StandardMetadata.from_pyproject(self._config, self._source_dir)
else:
print(
'{yellow}{bold}! Using Meson to generate the project metadata '
'(no `project` section in pyproject.toml){reset}'.format(**_STYLES)
)
self._metadata = None

if self._metadata:
self._validate_metadata()

# load meson args
for key in self._get_config_key('args'):
self._meson_args[key].extend(self._get_config_key(f'args.{key}'))
# XXX: We should validate the user args to make sure they don't conflict with ours.

self._check_for_unknown_config_keys({
'args': _MESON_ARGS_KEYS,
})

# meson arguments from the command line take precedence over
# arguments from the configuration file thus are added later
if meson_args:
for key, value in meson_args.items():
self._meson_args[key].extend(value)

# make sure the build dir exists
self._build_dir.mkdir(exist_ok=True, parents=True)
self._install_dir.mkdir(exist_ok=True, parents=True)
self._config.meson_args.setup.extend(('--cross-file', os.fspath(self._meson_cross_file)))

# write the native file
native_file_data = textwrap.dedent(f'''
Expand All @@ -768,14 +750,6 @@ def __init__( # noqa: C901
if self._metadata and 'version' in self._metadata.dynamic:
self._metadata.version = self.version

def _get_config_key(self, key: str) -> Any:
value: Any = self._config
for part in f'tool.meson-python.{key}'.split('.'):
if not isinstance(value, Mapping):
raise ConfigError(f'Configuration entry "tool.meson-python.{key}" should be a TOML table not {type(value)}')
value = value.get(part, {})
return value

def _run(self, cmd: Sequence[str]) -> None:
"""Invoke a subprocess."""
print('{cyan}{bold}+ {}{reset}'.format(' '.join(cmd), **_STYLES))
Expand Down Expand Up @@ -804,7 +778,7 @@ def _configure(self, reconfigure: bool = False) -> None:
sys_paths['platlib'],

# user args
*self._meson_args['setup'],
*self._meson_args.setup,
]
if reconfigure:
setup_args.insert(0, '--reconfigure')
Expand Down Expand Up @@ -834,17 +808,6 @@ def _validate_metadata(self) -> None:
f'expected {self._metadata.requires_python}'
)

def _check_for_unknown_config_keys(self, valid_args: Mapping[str, Collection[str]]) -> None:
config = self._config.get('tool', {}).get('meson-python', {})

for key, valid_subkeys in config.items():
if key not in valid_args:
raise ConfigError(f'Unknown configuration key "tool.meson-python.{key}"')

for subkey in valid_args[key]:
if subkey not in valid_subkeys:
raise ConfigError(f'Unknown configuration key "tool.meson-python.{key}.{subkey}"')

@cached_property
def _wheel_builder(self) -> _WheelBuilder:
return _WheelBuilder(
Expand All @@ -860,14 +823,14 @@ def _wheel_builder(self) -> _WheelBuilder:
def build_commands(self, install_dir: Optional[pathlib.Path] = None) -> Sequence[Sequence[str]]:
assert self._ninja is not None # help mypy out
return (
(self._ninja, *self._meson_args['compile'],),
(self._ninja, *self._meson_args.compile,),
(
'meson',
'install',
'--only-changed',
'--destdir',
os.fspath(install_dir or self._install_dir),
*self._meson_args['install'],
*self._meson_args.install,
),
)

Expand All @@ -881,14 +844,12 @@ def build(self) -> None:
@contextlib.contextmanager
def with_temp_working_dir(
cls,
hook_settings: BuildHookSettings | None = None,
source_dir: Path = os.path.curdir,
build_dir: Optional[Path] = None,
meson_args: Optional[MesonArgs] = None,
editable_verbose: bool = False,
) -> Iterator[Project]:
"""Creates a project instance pointing to a temporary working directory."""
with tempfile.TemporaryDirectory(prefix='.mesonpy-', dir=os.fspath(source_dir)) as tmpdir:
yield cls(source_dir, tmpdir, build_dir, meson_args, editable_verbose)
yield cls(source_dir, tmpdir, hook_settings)

@functools.lru_cache()
def _info(self, name: str) -> Dict[str, Any]:
Expand All @@ -909,7 +870,7 @@ def _install_plan(self) -> Dict[str, Dict[str, Dict[str, str]]]:
# parse install args for install tags (--tags)
parser = argparse.ArgumentParser()
parser.add_argument('--tags')
args, _ = parser.parse_known_args(self._meson_args['install'])
args, _ = parser.parse_known_args(self._meson_args.install)

# filter the install_plan for files that do not fit the install tags
if args.tags:
Expand Down Expand Up @@ -1010,7 +971,7 @@ def pep621(self) -> bool:
def sdist(self, directory: Path) -> pathlib.Path:
"""Generates a sdist (source distribution) in the specified directory."""
# generate meson dist file
self._run(['meson', 'dist', '--allow-dirty', '--no-tests', '--formats', 'gztar', *self._meson_args['dist']])
self._run(['meson', 'dist', '--allow-dirty', '--no-tests', '--formats', 'gztar', *self._meson_args.dist])

# move meson dist file to output path
dist_name = f'{self.name}-{self.version}'
Expand Down Expand Up @@ -1074,7 +1035,7 @@ def wheel(self, directory: Path) -> pathlib.Path:
return file

def editable(self, directory: Path) -> pathlib.Path:
file = self._wheel_builder.build_editable(directory, self._editable_verbose)
file = self._wheel_builder.build_editable(directory, self._hook_settings.editable_verbose)
assert isinstance(file, pathlib.Path)
return file

Expand All @@ -1091,51 +1052,10 @@ def _project(config_settings: Optional[Dict[Any, Any]]) -> Iterator[Project]:
for key, value in config_settings.items()
}

builddir_value = config_settings.get('builddir', {})
if len(builddir_value) > 0:
if len(builddir_value) != 1:
raise ConfigError('Only one value for configuration entry "builddir" can be specified')
builddir = builddir_value[0]
if not isinstance(builddir, str):
raise ConfigError(f'Configuration entry "builddir" should be a string not {type(builddir)}')
else:
builddir = None

def _validate_string_collection(key: str) -> None:
assert isinstance(config_settings, Mapping)
problematic_items: Sequence[Any] = list(filter(None, (
item if not isinstance(item, str) else None
for item in config_settings.get(key, ())
)))
if problematic_items:
s = ', '.join(f'"{item}" ({type(item)})' for item in problematic_items)
raise ConfigError(f'Configuration entries for "{key}" must be strings but contain: {s}')

meson_args_keys = _MESON_ARGS_KEYS
meson_args_cli_keys = tuple(f'{key}-args' for key in meson_args_keys)

for key in config_settings:
known_keys = ('builddir', 'editable-verbose', *meson_args_cli_keys)
if key not in known_keys:
import difflib
matches = difflib.get_close_matches(key, known_keys, n=3)
if len(matches):
alternatives = ' or '.join(f'"{match}"' for match in matches)
raise ConfigError(f'Unknown configuration entry "{key}". Did you mean {alternatives}?')
else:
raise ConfigError(f'Unknown configuration entry "{key}"')

for key in meson_args_cli_keys:
_validate_string_collection(key)

with Project.with_temp_working_dir(
build_dir=builddir,
meson_args=typing.cast(MesonArgs, {
key: config_settings.get(f'{key}-args', ())
for key in meson_args_keys
}),
editable_verbose=bool(config_settings.get('editable-verbose'))
) as project:
hook_settings = BuildHookSettings.from_config_settings(config_settings)
print(config_settings, hook_settings)

with Project.with_temp_working_dir(hook_settings) as project:
yield project


Expand Down Expand Up @@ -1169,8 +1089,13 @@ def _pyproject_hook(func: Callable[P, T]) -> Callable[P, T]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
try:
return func(*args, **kwargs)
except Error as exc:
print('{red}meson-python: error:{reset} {msg}'.format(msg=str(exc), **_STYLES))
except (Error, pyproject_metadata.ConfigurationError) as exc:
print((
'{red}meson-python: error:{reset}\n'
'{red}>{reset}\n' +
textwrap.indent(str(exc).strip(), prefix='{red}>{reset} ') + '\n' +
'{red}>{reset}\n'
).format(**_STYLES))
raise SystemExit(1) from exc
return wrapper

Expand Down
Loading