From b5a53e44ee854aba1a66ceba03ae214975c45f8b Mon Sep 17 00:00:00 2001 From: Christopher Barber Date: Sat, 13 Apr 2024 12:15:07 -0400 Subject: [PATCH] Transfer execute bits (fixes #135) --- CHANGELOG.md | 3 +- doc/guide/limitations.md | 8 ++++ src/whl2conda/VERSION | 2 +- src/whl2conda/api/converter.py | 10 +---- src/whl2conda/cli/build.py | 2 +- src/whl2conda/impl/wheel.py | 51 +++++++++++++++++++++++++ test-projects/setup/scripts/myscript.py | 15 ++++++++ test-projects/setup/setup.py | 1 + test/api/validator.py | 5 +-- test/cli/test_convert.py | 2 +- test/impl/test_wheel.py | 44 +++++++++++++++++++++ 11 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 src/whl2conda/impl/wheel.py create mode 100755 test-projects/setup/scripts/myscript.py create mode 100644 test/impl/test_wheel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c9928f3..6aeb507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # whl2conda changes -## [24.2.0] - *in progress* +## [24.4.0] - *in progress* ### Bug fixes +* Transfer executable file permissions from wheel (#135) * Correct typos in documentation. ## [24.1.2] - 2024-1-28 diff --git a/doc/guide/limitations.md b/doc/guide/limitations.md index 3ea3c90..fb8b159 100644 --- a/doc/guide/limitations.md +++ b/doc/guide/limitations.md @@ -13,6 +13,14 @@ in dependencies and probably will not occur that often in practice. We handle this by simplying changinge `===` to `==` but since this will often not work we also issue a warning. +## File permissions are not copied when run on Windows + +Executable file permissions are copied from the wheel when conversion +is run on MacOS or Linux but not Windows. When converting packages that +contain scripts with execute permissions (intended for use on Linux/MacOS), +make sure to avoid Windows when doing the conversion +(see issue [issue 135](https://github.com/zuzukin/whl2conda/issues/135)) + ## Cannot convert from sdist Conversion from python sdist distributions is not currently supported. diff --git a/src/whl2conda/VERSION b/src/whl2conda/VERSION index 5003ba8..ee3b424 100644 --- a/src/whl2conda/VERSION +++ b/src/whl2conda/VERSION @@ -1 +1 @@ -24.2.0 +24.4.0 diff --git a/src/whl2conda/api/converter.py b/src/whl2conda/api/converter.py index e41d9b0..7b78d95 100644 --- a/src/whl2conda/api/converter.py +++ b/src/whl2conda/api/converter.py @@ -36,13 +36,13 @@ from typing import Any, NamedTuple, Optional, Sequence # third party -from wheel.wheelfile import WheelFile from conda_package_handling.api import create as create_conda_pkg # this project from ..__about__ import __version__ from ..impl.prompt import bool_input from ..impl.pyproject import CondaPackageFormat +from ..impl.wheel import unpack_wheel from .stdrename import load_std_renames __all__ = [ @@ -296,7 +296,6 @@ class Wheel2CondaConverter: wheel_path: Path out_dir: Path dry_run: bool = False - wheel: Optional[WheelFile] out_format: CondaPackageFormat = CondaPackageFormat.V2 overwrite: bool = False keep_pip_dependencies: bool = False @@ -885,13 +884,8 @@ def translate_version_spec(self, pip_version: str) -> str: def _extract_wheel(self, temp_dir: Path) -> Path: self.logger.info("Reading %s", self.wheel_path) - wheel = WheelFile(self.wheel_path) wheel_dir = temp_dir / "wheel-files" - wheel.extractall(wheel_dir) - if self.logger.getEffectiveLevel() <= logging.DEBUG: - for wheel_file in wheel_dir.glob("**/*"): - if wheel_file.is_file(): - self._debug("Extracted %s", wheel_file.relative_to(wheel_dir)) + unpack_wheel(self.wheel_path, wheel_dir, logger=self.logger) return wheel_dir def _warn(self, msg, *args): diff --git a/src/whl2conda/cli/build.py b/src/whl2conda/cli/build.py index 1104aa5..0ab254b 100644 --- a/src/whl2conda/cli/build.py +++ b/src/whl2conda/cli/build.py @@ -122,7 +122,7 @@ def run(self) -> None: finally: self._cleanup() end = time.time() - print(f"Elapsed time: {end-start:f} seconds") + print(f"Elapsed time: {end - start:f} seconds") def _render_recipe(self): conda_bld = get_conda_bld_path() diff --git a/src/whl2conda/impl/wheel.py b/src/whl2conda/impl/wheel.py new file mode 100644 index 0000000..77ea45e --- /dev/null +++ b/src/whl2conda/impl/wheel.py @@ -0,0 +1,51 @@ +# Copyright 2024 Christopher Barber +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Utilities for working with wheel files +""" + +import logging +from pathlib import Path +from typing import Optional, Union +from wheel.wheelfile import WheelFile + +__all__ = ["unpack_wheel"] + +def unpack_wheel( + wheel: Union[Path, str], + dest_dir: Union[Path|str], + *, + logger: Optional[logging.Logger] = None +) -> None: + """ + Unpack wheel into specified directory. + + Args: + wheel: location of wheel file to unpack + dest_dir: destination directory. + """ + wheel_path = Path(wheel) + dest_path = Path(dest_dir) + dest_path.mkdir(parents=True, exist_ok=True) + + logger = logger or logging.getLogger(__name__) + + with WheelFile(wheel_path) as wf: + for zipinfo in wf.filelist: + extracted = Path(wf.extract(zipinfo, dest_path)) + # copy file permissions (see https://github.com/python/cpython/issues/59999) + # has no effect on Windows + extracted.chmod(zipinfo.external_attr >> 16 & 0o777) + logger.debug("Extracted %s", extracted.relative_to(dest_path)) diff --git a/test-projects/setup/scripts/myscript.py b/test-projects/setup/scripts/myscript.py new file mode 100755 index 0000000..eb8f585 --- /dev/null +++ b/test-projects/setup/scripts/myscript.py @@ -0,0 +1,15 @@ +# Copyright 2024 Christopher Barber +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +print("hello world") diff --git a/test-projects/setup/setup.py b/test-projects/setup/setup.py index be351fd..c1a46f8 100644 --- a/test-projects/setup/setup.py +++ b/test-projects/setup/setup.py @@ -40,4 +40,5 @@ ], extras_require={'bdev': ['black']}, packages=["mypkg"], + scripts=["scripts/myscript.py"], ) diff --git a/test/api/validator.py b/test/api/validator.py index 464ca64..b7c954f 100644 --- a/test/api/validator.py +++ b/test/api/validator.py @@ -28,10 +28,10 @@ import conda_package_handling.api as cphapi import pytest -from wheel.wheelfile import WheelFile from whl2conda.__about__ import __version__ from whl2conda.api.converter import RequiresDistEntry, Wheel2CondaConverter +from whl2conda.impl.wheel import unpack_wheel class PackageValidator: @@ -145,8 +145,7 @@ def _unpack_wheel(self, wheel_path: Path) -> Path: if wheel_path.is_dir(): shutil.copytree(wheel_path, unpack_dir, dirs_exist_ok=True) else: - wheel = WheelFile(wheel_path) - wheel.extractall(unpack_dir) + unpack_wheel(wheel_path, unpack_dir) return unpack_dir diff --git a/test/cli/test_convert.py b/test/cli/test_convert.py index 92caf78..0c830e2 100644 --- a/test/cli/test_convert.py +++ b/test/cli/test_convert.py @@ -395,7 +395,7 @@ def teardown(self) -> None: """Make sure all test cases have been run.""" for i, case in enumerate(self.cases): if not case.was_run: - pytest.fail(f"Case #{i+1} was not run") + pytest.fail(f"Case #{i + 1} was not run") @pytest.fixture diff --git a/test/impl/test_wheel.py b/test/impl/test_wheel.py new file mode 100644 index 0000000..2ab10e9 --- /dev/null +++ b/test/impl/test_wheel.py @@ -0,0 +1,44 @@ +# Copyright 2024 Christopher Barber +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Unit tests for the whl2conda.impl.wheel module +""" +import platform +import stat +from pathlib import Path + +import pytest + +from whl2conda.impl.wheel import unpack_wheel + +from ..test_packages import setup_wheel + +def test_unpack_wheel( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + setup_wheel: Path, +) -> None: + """ + Unit test for unpack_wheel + """ + + unpack_wheel(setup_wheel, tmp_path) + + if not platform.system() == "Windows": + # Regression case for #135 + script_paths = list(tmp_path.rglob("**/scripts/*.py")) + assert script_paths + for script_path in script_paths: + assert script_path.stat().st_mode & stat.S_IXUSR