Skip to content

Commit 2458962

Browse files
authored
Merge pull request #90 from pradyunsg/preserve-executable-bit
2 parents bf68f7b + 7f25a1a commit 2458962

File tree

7 files changed

+127
-26
lines changed

7 files changed

+127
-26
lines changed

src/installer/_core.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def install(
9393
written_records.append((Scheme("scripts"), record))
9494

9595
# Write all the files from the wheel.
96-
for record_elements, stream in source.get_contents():
96+
for record_elements, stream, is_executable in source.get_contents():
9797
source_record = RecordEntry.from_elements(*record_elements)
9898
path = source_record.path
9999
# Skip the RECORD, which is written at the end, based on this info.
@@ -110,6 +110,7 @@ def install(
110110
scheme=scheme,
111111
path=destination_path,
112112
stream=stream,
113+
is_executable=is_executable,
113114
)
114115
written_records.append((scheme, record))
115116

@@ -122,6 +123,7 @@ def install(
122123
scheme=root_scheme,
123124
path=path,
124125
stream=other_stream,
126+
is_executable=is_executable,
125127
)
126128
written_records.append((root_scheme, record))
127129

src/installer/destinations.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
construct_record_file,
1212
copyfileobj_with_hashing,
1313
fix_shebang,
14+
make_file_executable,
1415
)
1516

1617
if TYPE_CHECKING:
@@ -44,13 +45,18 @@ def write_script(
4445
raise NotImplementedError
4546

4647
def write_file(
47-
self, scheme: Scheme, path: Union[str, "os.PathLike[str]"], stream: BinaryIO
48+
self,
49+
scheme: Scheme,
50+
path: Union[str, "os.PathLike[str]"],
51+
stream: BinaryIO,
52+
is_executable: bool,
4853
) -> RecordEntry:
4954
"""Write a file to correct ``path`` within the ``scheme``.
5055
5156
:param scheme: scheme to write the file in (like "purelib", "platlib" etc).
5257
:param path: path within that scheme
5358
:param stream: contents of the file
59+
:param is_executable: whether the file should be made executable
5460
5561
The stream would be closed by the caller, after this call.
5662
@@ -108,12 +114,19 @@ def __init__(
108114
self.script_kind = script_kind
109115
self.hash_algorithm = hash_algorithm
110116

111-
def write_to_fs(self, scheme: Scheme, path: str, stream: BinaryIO) -> RecordEntry:
117+
def write_to_fs(
118+
self,
119+
scheme: Scheme,
120+
path: str,
121+
stream: BinaryIO,
122+
is_executable: bool,
123+
) -> RecordEntry:
112124
"""Write contents of ``stream`` to the correct location on the filesystem.
113125
114126
:param scheme: scheme to write the file in (like "purelib", "platlib" etc).
115127
:param path: path within that scheme
116128
:param stream: contents of the file
129+
:param is_executable: whether the file should be made executable
117130
118131
- Ensures that an existing file is not being overwritten.
119132
- Hashes the written content, to determine the entry in the ``RECORD`` file.
@@ -130,16 +143,24 @@ def write_to_fs(self, scheme: Scheme, path: str, stream: BinaryIO) -> RecordEntr
130143
with open(target_path, "wb") as f:
131144
hash_, size = copyfileobj_with_hashing(stream, f, self.hash_algorithm)
132145

146+
if is_executable:
147+
make_file_executable(target_path)
148+
133149
return RecordEntry(path, Hash(self.hash_algorithm, hash_), size)
134150

135151
def write_file(
136-
self, scheme: Scheme, path: Union[str, "os.PathLike[str]"], stream: BinaryIO
152+
self,
153+
scheme: Scheme,
154+
path: Union[str, "os.PathLike[str]"],
155+
stream: BinaryIO,
156+
is_executable: bool,
137157
) -> RecordEntry:
138158
"""Write a file to correct ``path`` within the ``scheme``.
139159
140160
:param scheme: scheme to write the file in (like "purelib", "platlib" etc).
141161
:param path: path within that scheme
142162
:param stream: contents of the file
163+
:param is_executable: whether the file should be made executable
143164
144165
- Changes the shebang for files in the "scripts" scheme.
145166
- Uses :py:meth:`SchemeDictionaryDestination.write_to_fs` for the
@@ -149,9 +170,11 @@ def write_file(
149170

150171
if scheme == "scripts":
151172
with fix_shebang(stream, self.interpreter) as stream_with_different_shebang:
152-
return self.write_to_fs(scheme, path_, stream_with_different_shebang)
173+
return self.write_to_fs(
174+
scheme, path_, stream_with_different_shebang, is_executable
175+
)
153176

154-
return self.write_to_fs(scheme, path_, stream)
177+
return self.write_to_fs(scheme, path_, stream, is_executable)
155178

156179
def write_script(
157180
self, name: str, module: str, attr: str, section: "ScriptSection"
@@ -174,7 +197,9 @@ def write_script(
174197
script_name, data = script.generate(self.interpreter, self.script_kind)
175198

176199
with io.BytesIO(data) as stream:
177-
entry = self.write_to_fs(Scheme("scripts"), script_name, stream)
200+
entry = self.write_to_fs(
201+
Scheme("scripts"), script_name, stream, is_executable=True
202+
)
178203

179204
path = os.path.join(self.scheme_dict[Scheme("scripts")], script_name)
180205
mode = os.stat(path).st_mode
@@ -206,4 +231,6 @@ def prefix_for_scheme(file_scheme: str) -> Optional[str]:
206231
return path + "/"
207232

208233
with construct_record_file(records, prefix_for_scheme) as record_stream:
209-
self.write_to_fs(scheme, record_file_path, record_stream)
234+
self.write_to_fs(
235+
scheme, record_file_path, record_stream, is_executable=False
236+
)

src/installer/sources.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
import os
44
import posixpath
5+
import stat
56
import zipfile
67
from contextlib import contextmanager
78
from typing import BinaryIO, Iterator, List, Tuple, cast
89

910
import installer.records
1011
import installer.utils
1112

12-
WheelContentElement = Tuple[Tuple[str, str, str], BinaryIO]
13+
WheelContentElement = Tuple[Tuple[str, str, str], BinaryIO, bool]
1314

1415

1516
__all__ = ["WheelSource", "WheelFile"]
@@ -68,20 +69,21 @@ def get_contents(self) -> Iterator[WheelContentElement]:
6869
"""Sequential access to all contents of the wheel (including dist-info files).
6970
7071
This method should return an iterable. Each value from the iterable must be a
71-
tuple containing 2 elements:
72+
tuple containing 3 elements:
7273
7374
- record: 3-value tuple, to pass to
7475
:py:meth:`RecordEntry.from_elements <installer.records.RecordEntry.from_elements>`.
7576
- stream: An :py:class:`io.BufferedReader` object, providing the contents of the
7677
file at the location provided by the first element (path).
78+
- is_executable: A boolean, representing whether the item has an executable bit.
7779
7880
All paths must be relative to the root of the wheel.
7981
8082
Sample usage/behaviour::
8183
8284
>>> iterable = wheel_source.get_contents()
8385
>>> next(iterable)
84-
(('pkg/__init__.py', '', '0'), <...>)
86+
(('pkg/__init__.py', '', '0'), <...>, False)
8587
8688
This method may be called multiple times. Each iterable returned must
8789
provide the same content upon reading from a specific file's stream.
@@ -158,6 +160,11 @@ def get_contents(self) -> Iterator[WheelContentElement]:
158160
item.filename,
159161
) # should not happen for valid wheels
160162

163+
# Borrowed from:
164+
# https://github.com/pypa/pip/blob/0f21fb92/src/pip/_internal/utils/unpacking.py#L96-L100
165+
mode = item.external_attr >> 16
166+
is_executable = bool(mode and stat.S_ISREG(mode) and mode & 0o111)
167+
161168
with self._zipfile.open(item) as stream:
162169
stream_casted = cast("BinaryIO", stream)
163-
yield record, stream_casted
170+
yield record, stream_casted, is_executable

src/installer/utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
NewType,
2020
Optional,
2121
Tuple,
22+
Union,
2223
cast,
2324
)
2425

@@ -38,6 +39,7 @@
3839
"fix_shebang",
3940
"construct_record_file",
4041
"parse_entrypoints",
42+
"make_file_executable",
4143
"WheelFilename",
4244
"SCHEME_NAMES",
4345
]
@@ -229,3 +231,17 @@ def parse_entrypoints(text: str) -> Iterable[Tuple[str, str, str, "ScriptSection
229231
script_section = cast("ScriptSection", section[: -len("_scripts")])
230232

231233
yield name, module, attrs, script_section
234+
235+
236+
def _current_umask() -> int:
237+
"""Get the current umask which involves having to set it temporarily."""
238+
mask = os.umask(0)
239+
os.umask(mask)
240+
return mask
241+
242+
243+
# Borrowed from:
244+
# https://github.com/pypa/pip/blob/0f21fb92/src/pip/_internal/utils/unpacking.py#L93
245+
def make_file_executable(path: Union[str, "os.PathLike[str]"]) -> None:
246+
"""Make the file at the provided path executable."""
247+
os.chmod(path, (0o777 & ~_current_umask() | 0o111))

0 commit comments

Comments
 (0)