diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 7051a6757..632208a81 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -18,7 +18,6 @@ import functools import importlib.machinery import io -import itertools import json import os import pathlib @@ -271,12 +270,12 @@ def _wheel_files(self) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]: @property def _has_internal_libs(self) -> bool: - return bool(self._wheel_files['mesonpy-libs']) + return bool(self._wheel_files.get('mesonpy-libs')) @property def _has_extension_modules(self) -> bool: # Assume that all code installed in {platlib} is Python ABI dependent. - return bool(self._wheel_files['platlib']) + return bool(self._wheel_files.get('platlib')) @property def normalized_name(self) -> str: @@ -425,7 +424,6 @@ def _is_native(self, file: Union[str, pathlib.Path]) -> bool: def _install_path( # noqa: C901 self, wheel_file: mesonpy._wheelfile.WheelFile, - counter: mesonpy._util.CLICounter, origin: Path, destination: pathlib.Path, ) -> None: @@ -436,7 +434,6 @@ def _install_path( # noqa: C901 library directory on Linux wheels for eg. """ location = destination.as_posix() - counter.update(location) # fix file if os.path.isdir(origin): @@ -501,34 +498,33 @@ def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None: def build(self, directory: Path) -> pathlib.Path: # ensure project is built self._project.build() - # install the project - self._project.install() - wheel_file = pathlib.Path(directory, f'{self.name}.whl') + # install project in temporary destination directory + with tempfile.TemporaryDirectory() as destdir: + self._project.install(destdir) - with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl: - self._wheel_write_metadata(whl) + wheel_file = pathlib.Path(directory, f'{self.name}.whl') + + with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl: + self._wheel_write_metadata(whl) + + with mesonpy._util.cli_counter(sum(len(x) for x in self._wheel_files.values())) as counter: + + root = 'purelib' if self.is_pure else 'platlib' - with mesonpy._util.cli_counter( - len(list(itertools.chain.from_iterable(self._wheel_files.values()))), - ) as counter: - # install root scheme files - root_scheme = 'purelib' if self.is_pure else 'platlib' - for destination, origin in self._wheel_files[root_scheme]: - self._install_path(whl, counter, origin, destination) - - # install bundled libraries - for destination, origin in self._wheel_files['mesonpy-libs']: - destination = pathlib.Path(f'.{self._project.name}.mesonpy.libs', destination) - self._install_path(whl, counter, origin, destination) - - # install the other schemes - for scheme in self._wheel_files.keys(): - if scheme in (root_scheme, 'mesonpy-libs'): - continue - for destination, origin in self._wheel_files[scheme]: - destination = pathlib.Path(self.data_dir, scheme, destination) - self._install_path(whl, counter, origin, destination) + for path, entries in self._wheel_files.items(): + for dst, src in entries: + counter.update(src) + + if path == root: + pass + elif path == 'mesonpy-libs': + # custom installation path for bundled libraries + dst = pathlib.Path(f'.{self._project.name}.mesonpy.libs', dst) + else: + dst = pathlib.Path(self.data_dir, path, dst) + + self._install_path(whl, src, dst) return wheel_file @@ -645,16 +641,13 @@ class Project(): def __init__( # noqa: C901 self, source_dir: Path, - working_dir: Path, - build_dir: Optional[Path] = None, + build_dir: Path, meson_args: Optional[MesonArgs] = None, editable_verbose: bool = False, ) -> None: 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._build_dir = pathlib.Path(build_dir).absolute() self._editable_verbose = editable_verbose - 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) @@ -669,7 +662,6 @@ def __init__( # noqa: C901 # 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-'): @@ -825,24 +817,11 @@ def build(self) -> None: """Build the Meson project.""" self._run(self._build_command) - def install(self) -> None: + def install(self, destdir: Path) -> None: """Install the Meson project.""" - destdir = os.fspath(self._install_dir) + destdir = os.fspath(destdir) self._run(['meson', 'install', '--quiet', '--no-rebuild', '--destdir', destdir, *self._meson_args['install']]) - @classmethod - @contextlib.contextmanager - def with_temp_working_dir( - cls, - 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) - @functools.lru_cache() def _info(self, name: str) -> Any: """Read info from meson-info directory.""" @@ -990,18 +969,19 @@ def editable(self, directory: Path) -> pathlib.Path: @contextlib.contextmanager -def _project(config_settings: Optional[Dict[Any, Any]]) -> Iterator[Project]: +def _project(config_settings: Optional[Dict[Any, Any]] = None) -> Iterator[Project]: """Create the project given the given config settings.""" settings = _validate_config_settings(config_settings or {}) - meson_args = {name: settings.get(f'{name}-args', []) for name in _MESON_ARGS_KEYS} - - with Project.with_temp_working_dir( - build_dir=settings.get('builddir'), - meson_args=typing.cast(MesonArgs, meson_args), - editable_verbose=bool(settings.get('editable-verbose')) - ) as project: - yield project + meson_args = typing.cast(MesonArgs, {name: settings.get(f'{name}-args', []) for name in _MESON_ARGS_KEYS}) + source_dir = os.path.curdir + build_dir = settings.get('builddir') + editable_verbose = bool(settings.get('editable-verbose')) + + with contextlib.ExitStack() as ctx: + if build_dir is None: + build_dir = ctx.enter_context(tempfile.TemporaryDirectory(prefix='.mesonpy-', dir=source_dir)) + yield Project(source_dir, build_dir, meson_args, editable_verbose) def _parse_version_string(string: str) -> Tuple[int, ...]: diff --git a/tests/test_editable.py b/tests/test_editable.py index 4de8640de..ac9319d23 100644 --- a/tests/test_editable.py +++ b/tests/test_editable.py @@ -61,13 +61,12 @@ def test_collect(package_complex): def test_mesonpy_meta_finder(package_complex, tmp_path): # build a package in a temporary directory mesonpy.Project(package_complex, tmp_path) - build_path = tmp_path / 'build' # point the meta finder to the build directory - finder = _editable.MesonpyMetaFinder({'complex'}, os.fspath(build_path), ['ninja']) + finder = _editable.MesonpyMetaFinder({'complex'}, os.fspath(tmp_path), ['ninja']) # check repr - assert repr(finder) == f'MesonpyMetaFinder({str(build_path)!r})' + assert repr(finder) == f'MesonpyMetaFinder({str(tmp_path)!r})' # verify that we can look up a pure module in the source directory spec = finder.find_spec('complex') @@ -79,7 +78,7 @@ def test_mesonpy_meta_finder(package_complex, tmp_path): spec = finder.find_spec('complex.test') assert spec.name == 'complex.test' assert isinstance(spec.loader, _editable.ExtensionFileLoader) - assert spec.origin == os.fspath(build_path / f'test{EXT_SUFFIX}') + assert spec.origin == os.fspath(tmp_path / f'test{EXT_SUFFIX}') try: # install the finder in the meta path @@ -89,7 +88,7 @@ def test_mesonpy_meta_finder(package_complex, tmp_path): assert complex.__spec__.origin == os.fspath(package_complex / 'complex/__init__.py') assert complex.__file__ == os.fspath(package_complex / 'complex/__init__.py') import complex.test - assert complex.test.__spec__.origin == os.fspath(build_path / f'test{EXT_SUFFIX}') + assert complex.test.__spec__.origin == os.fspath(tmp_path / f'test{EXT_SUFFIX}') assert complex.test.answer() == 42 import complex.namespace.foo assert complex.namespace.foo.__spec__.origin == os.fspath(package_complex / 'complex/namespace/foo.py') @@ -128,7 +127,7 @@ def test_resources(tmp_path): mesonpy.Project(package_path, tmp_path) # point the meta finder to the build directory - finder = _editable.MesonpyMetaFinder({'simple'}, os.fspath(tmp_path / 'build'), ['ninja']) + finder = _editable.MesonpyMetaFinder({'simple'}, os.fspath(tmp_path), ['ninja']) # verify that we can look up resources spec = finder.find_spec('simple') @@ -147,7 +146,7 @@ def test_importlib_resources(tmp_path): mesonpy.Project(package_path, tmp_path) # point the meta finder to the build directory - finder = _editable.MesonpyMetaFinder({'simple'}, os.fspath(tmp_path / 'build'), ['ninja']) + finder = _editable.MesonpyMetaFinder({'simple'}, os.fspath(tmp_path), ['ninja']) try: # install the finder in the meta path diff --git a/tests/test_options.py b/tests/test_options.py index 20d5ea332..2d0ed00fa 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -32,6 +32,6 @@ def test_ndebug(package_purelib_and_platlib, tmp_path, args, expected): # compile a C source file (the trailing ^ is used to # specify the target that is the first output of the rule # containing the specified source file). - ['ninja', '-C', os.fspath(project._build_dir), '-t', 'commands', '../../plat.c^'], + ['ninja', '-C', os.fspath(project._build_dir), '-t', 'commands', '../plat.c^'], stdout=subprocess.PIPE, check=True).stdout assert (b'-DNDEBUG' in command) == expected diff --git a/tests/test_project.py b/tests/test_project.py index 494a10e1d..a15a43e6c 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -31,7 +31,7 @@ ] ) def test_name(package): - with chdir(package_dir / package), mesonpy.Project.with_temp_working_dir() as project: + with chdir(package_dir / package), mesonpy._project() as project: assert project.name == package.replace('-', '_') @@ -43,37 +43,37 @@ def test_name(package): ] ) def test_version(package): - with chdir(package_dir / package), mesonpy.Project.with_temp_working_dir() as project: + with chdir(package_dir / package), mesonpy._project() as project: assert project.version == '1.0.0' def test_unsupported_dynamic(package_unsupported_dynamic): with pytest.raises(pyproject_metadata.ConfigurationError, match='Unsupported dynamic fields: "dependencies"'): - with mesonpy.Project.with_temp_working_dir(): + with mesonpy._project(): pass def test_unsupported_python_version(package_unsupported_python_version): with pytest.raises(mesonpy.MesonBuilderError, match='Package requires Python version ==1.0.0'): - with mesonpy.Project.with_temp_working_dir(): + with mesonpy._project(): pass def test_missing_version(package_missing_version): with pytest.raises(pyproject_metadata.ConfigurationError, match='Required "project.version" field is missing'): - with mesonpy.Project.with_temp_working_dir(): + with mesonpy._project(): pass def test_missing_meson_version(package_missing_meson_version): with pytest.raises(pyproject_metadata.ConfigurationError, match='Section "project" missing in pyproject.toml'): - with mesonpy.Project.with_temp_working_dir(): + with mesonpy._project(): pass def test_missing_dynamic_version(package_missing_dynamic_version): with pytest.raises(pyproject_metadata.ConfigurationError, match='Field "version" declared as dynamic but'): - with mesonpy.Project.with_temp_working_dir(): + with mesonpy._project(): pass @@ -222,7 +222,7 @@ def test_invalid_build_dir(package_pure, tmp_path, mocker): meson.reset_mock() # corrupting the build direcory setup is run again - tmp_path.joinpath('build/meson-private/coredata.dat').unlink() + tmp_path.joinpath('meson-private/coredata.dat').unlink() project = mesonpy.Project(package_pure, tmp_path) assert len(meson.call_args_list) == 1 assert meson.call_args_list[0].args[1][1] == 'setup' @@ -231,7 +231,7 @@ def test_invalid_build_dir(package_pure, tmp_path, mocker): meson.reset_mock() # removing the build directory things should still work - shutil.rmtree(tmp_path.joinpath('build')) + shutil.rmtree(tmp_path) project = mesonpy.Project(package_pure, tmp_path) assert len(meson.call_args_list) == 1 assert meson.call_args_list[0].args[1][1] == 'setup' diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 9991e9500..c3745aef4 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -235,7 +235,7 @@ def test_entrypoints(wheel_full_metadata): def test_top_level_modules(package_module_types): - with mesonpy.Project.with_temp_working_dir() as project: + with mesonpy._project() as project: assert set(project._wheel_builder.top_level_modules) == { 'file', 'package', @@ -245,7 +245,7 @@ def test_top_level_modules(package_module_types): def test_purelib_platlib_split(package_purelib_platlib_split, tmp_path): with pytest.raises(mesonpy.BuildError, match='The purelib-platlib-split package is split'): - with mesonpy.Project.with_temp_working_dir() as project: + with mesonpy._project() as project: project.wheel(tmp_path) @@ -303,7 +303,7 @@ def test_limited_api(wheel_limited_api): @pytest.mark.skipif(MESON_VERSION < (1, 2, 99), reason='Meson version too old') def test_limited_api_bad(package_limited_api, tmp_path): with pytest.raises(mesonpy.BuildError, match='The package declares compatibility with Python limited API but '): - with mesonpy.Project.with_temp_working_dir(meson_args={'setup': ['-Dextra=true']}) as project: + with mesonpy._project({'setup-args': ['-Dextra=true']}) as project: project.wheel(tmp_path)