Skip to content

Commit 777a499

Browse files
takluyvermgornyFFY00pre-commit-ci[bot]pradyunsg
authored
CLI to install to Python running installer (#94)
Co-authored-by: Michał Górny <[email protected]> Co-authored-by: Filipe Laíns <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Pradyun Gedam <[email protected]>
1 parent 2458962 commit 777a499

File tree

5 files changed

+250
-81
lines changed

5 files changed

+250
-81
lines changed

src/installer/__main__.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Installer CLI."""
2+
3+
import argparse
4+
import os.path
5+
import sys
6+
import sysconfig
7+
from typing import Dict, Optional, Sequence
8+
9+
import installer
10+
import installer.destinations
11+
import installer.sources
12+
import installer.utils
13+
14+
15+
def main_parser() -> argparse.ArgumentParser:
16+
"""Construct the main parser."""
17+
parser = argparse.ArgumentParser()
18+
parser.add_argument("wheel", type=str, help="wheel file to install")
19+
parser.add_argument(
20+
"--destdir",
21+
"-d",
22+
metavar="path",
23+
type=str,
24+
help="destination directory (prefix to prepend to each file)",
25+
)
26+
parser.add_argument(
27+
"--compile-bytecode",
28+
action="append",
29+
metavar="level",
30+
type=int,
31+
choices=[0, 1, 2],
32+
help="generate bytecode for the specified optimization level(s) (default=0, 1)",
33+
)
34+
parser.add_argument(
35+
"--no-compile-bytecode",
36+
action="store_true",
37+
help="don't generate bytecode for installed modules",
38+
)
39+
return parser
40+
41+
42+
def get_scheme_dict(distribution_name: str) -> Dict[str, str]:
43+
"""Calculate the scheme dictionary for the current Python environment."""
44+
scheme_dict = sysconfig.get_paths()
45+
46+
# calculate 'headers' path, not currently in sysconfig - see
47+
# https://bugs.python.org/issue44445. This is based on what distutils does.
48+
# TODO: figure out original vs normalised distribution names
49+
scheme_dict["headers"] = os.path.join(
50+
sysconfig.get_path(
51+
"include", vars={"installed_base": sysconfig.get_config_var("base")}
52+
),
53+
distribution_name,
54+
)
55+
56+
return scheme_dict
57+
58+
59+
def main(cli_args: Sequence[str], program: Optional[str] = None) -> None:
60+
"""Process arguments and perform the install."""
61+
parser = main_parser()
62+
if program:
63+
parser.prog = program
64+
args = parser.parse_args(cli_args)
65+
66+
bytecode_levels = args.compile_bytecode
67+
if args.no_compile_bytecode:
68+
bytecode_levels = []
69+
elif not bytecode_levels:
70+
bytecode_levels = [0, 1]
71+
72+
with installer.sources.WheelFile.open(args.wheel) as source:
73+
destination = installer.destinations.SchemeDictionaryDestination(
74+
get_scheme_dict(source.distribution),
75+
sys.executable,
76+
installer.utils.get_launcher_kind(),
77+
bytecode_optimization_levels=bytecode_levels,
78+
destdir=args.destdir,
79+
)
80+
installer.install(source, destination, {})
81+
82+
83+
if __name__ == "__main__": # pragma: no cover
84+
main(sys.argv[1:], "python -m installer")

src/installer/destinations.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
"""Handles all file writing and post-installation processing."""
22

3+
import compileall
34
import io
45
import os
5-
from typing import TYPE_CHECKING, BinaryIO, Dict, Iterable, Optional, Tuple, Union
6+
from pathlib import Path
7+
from typing import (
8+
TYPE_CHECKING,
9+
BinaryIO,
10+
Collection,
11+
Dict,
12+
Iterable,
13+
Optional,
14+
Tuple,
15+
Union,
16+
)
617

718
from installer.records import Hash, RecordEntry
819
from installer.scripts import Script
@@ -99,6 +110,8 @@ def __init__(
99110
interpreter: str,
100111
script_kind: "LauncherKind",
101112
hash_algorithm: str = "sha256",
113+
bytecode_optimization_levels: Collection[int] = (),
114+
destdir: Optional[str] = None,
102115
) -> None:
103116
"""Construct a ``SchemeDictionaryDestination`` object.
104117
@@ -108,11 +121,28 @@ def __init__(
108121
:param hash_algorithm: the hashing algorithm to use, which is a member
109122
of :any:`hashlib.algorithms_available` (ideally from
110123
:any:`hashlib.algorithms_guaranteed`).
124+
:param bytecode_optimization_levels: Compile cached bytecode for
125+
installed .py files with these optimization levels. The bytecode
126+
is specific to the minor version of Python (e.g. 3.10) used to
127+
generate it.
128+
:param destdir: A staging directory in which to write all files. This
129+
is expected to be the filesystem root at runtime, so embedded paths
130+
will be written as though this was the root.
111131
"""
112132
self.scheme_dict = scheme_dict
113133
self.interpreter = interpreter
114134
self.script_kind = script_kind
115135
self.hash_algorithm = hash_algorithm
136+
self.bytecode_optimization_levels = bytecode_optimization_levels
137+
self.destdir = destdir
138+
139+
def _path_with_destdir(self, scheme: Scheme, path: str) -> str:
140+
file = os.path.join(self.scheme_dict[scheme], path)
141+
if self.destdir is not None:
142+
file_path = Path(file)
143+
rel_path = file_path.relative_to(file_path.anchor)
144+
return os.path.join(self.destdir, rel_path)
145+
return file
116146

117147
def write_to_fs(
118148
self,
@@ -131,7 +161,7 @@ def write_to_fs(
131161
- Ensures that an existing file is not being overwritten.
132162
- Hashes the written content, to determine the entry in the ``RECORD`` file.
133163
"""
134-
target_path = os.path.join(self.scheme_dict[scheme], path)
164+
target_path = self._path_with_destdir(scheme, path)
135165
if os.path.exists(target_path):
136166
message = f"File already exists: {target_path}"
137167
raise FileExistsError(message)
@@ -201,20 +231,34 @@ def write_script(
201231
Scheme("scripts"), script_name, stream, is_executable=True
202232
)
203233

204-
path = os.path.join(self.scheme_dict[Scheme("scripts")], script_name)
234+
path = self._path_with_destdir(Scheme("scripts"), script_name)
205235
mode = os.stat(path).st_mode
206236
mode |= (mode & 0o444) >> 2
207237
os.chmod(path, mode)
208238

209239
return entry
210240

241+
def _compile_bytecode(self, scheme: Scheme, record: RecordEntry) -> None:
242+
"""Compile bytecode for a single .py file."""
243+
if scheme not in ("purelib", "platlib"):
244+
return
245+
246+
target_path = self._path_with_destdir(scheme, record.path)
247+
dir_path_to_embed = os.path.dirname( # Without destdir
248+
os.path.join(self.scheme_dict[scheme], record.path)
249+
)
250+
for level in self.bytecode_optimization_levels:
251+
compileall.compile_file(
252+
target_path, optimize=level, quiet=1, ddir=dir_path_to_embed
253+
)
254+
211255
def finalize_installation(
212256
self,
213257
scheme: Scheme,
214258
record_file_path: str,
215259
records: Iterable[Tuple[Scheme, RecordEntry]],
216260
) -> None:
217-
"""Finalize installation, by writing the ``RECORD`` file.
261+
"""Finalize installation, by writing the ``RECORD`` file & compiling bytecode.
218262
219263
:param scheme: scheme to write the ``RECORD`` file in
220264
:param record_file_path: path of the ``RECORD`` file with that scheme
@@ -230,7 +274,11 @@ def prefix_for_scheme(file_scheme: str) -> Optional[str]:
230274
)
231275
return path + "/"
232276

233-
with construct_record_file(records, prefix_for_scheme) as record_stream:
277+
record_list = list(records)
278+
with construct_record_file(record_list, prefix_for_scheme) as record_stream:
234279
self.write_to_fs(
235280
scheme, record_file_path, record_stream, is_executable=False
236281
)
282+
283+
for scheme, record in record_list:
284+
self._compile_bytecode(scheme, record)

tests/conftest.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import textwrap
2+
import zipfile
3+
4+
import pytest
5+
6+
7+
@pytest.fixture
8+
def fancy_wheel(tmp_path):
9+
path = tmp_path / "fancy-1.0.0-py2.py3-none-any.whl"
10+
files = {
11+
"fancy/": b"""""",
12+
"fancy/__init__.py": b"""\
13+
def main():
14+
print("I'm fancy.")
15+
""",
16+
"fancy/__main__.py": b"""\
17+
if __name__ == "__main__":
18+
from . import main
19+
main()
20+
""",
21+
"fancy-1.0.0.data/data/fancy/": b"""""",
22+
"fancy-1.0.0.data/data/fancy/data.py": b"""\
23+
# put me in data
24+
""",
25+
"fancy-1.0.0.dist-info/": b"""""",
26+
"fancy-1.0.0.dist-info/top_level.txt": b"""\
27+
fancy
28+
""",
29+
"fancy-1.0.0.dist-info/entry_points.txt": b"""\
30+
[console_scripts]
31+
fancy = fancy:main
32+
33+
[gui_scripts]
34+
fancy-gui = fancy:main
35+
""",
36+
"fancy-1.0.0.dist-info/WHEEL": b"""\
37+
Wheel-Version: 1.0
38+
Generator: magic (1.0.0)
39+
Root-Is-Purelib: true
40+
Tag: py3-none-any
41+
""",
42+
"fancy-1.0.0.dist-info/METADATA": b"""\
43+
Metadata-Version: 2.1
44+
Name: fancy
45+
Version: 1.0.0
46+
Summary: A fancy package
47+
Author: Agendaless Consulting
48+
Author-email: [email protected]
49+
License: MIT
50+
Keywords: fancy amazing
51+
Platform: UNKNOWN
52+
Classifier: Intended Audience :: Developers
53+
""",
54+
# The RECORD file is indirectly validated by the WheelFile, since it only
55+
# provides the items that are a part of the wheel.
56+
"fancy-1.0.0.dist-info/RECORD": b"""\
57+
fancy/__init__.py,,
58+
fancy/__main__.py,,
59+
fancy-1.0.0.data/data/fancy/data.py,,
60+
fancy-1.0.0.dist-info/top_level.txt,,
61+
fancy-1.0.0.dist-info/entry_points.txt,,
62+
fancy-1.0.0.dist-info/WHEEL,,
63+
fancy-1.0.0.dist-info/METADATA,,
64+
fancy-1.0.0.dist-info/RECORD,,
65+
""",
66+
}
67+
68+
with zipfile.ZipFile(path, "w") as archive:
69+
for name, indented_content in files.items():
70+
archive.writestr(
71+
name,
72+
textwrap.dedent(indented_content.decode("utf-8")).encode("utf-8"),
73+
)
74+
75+
return path

tests/test_main.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from installer.__main__ import get_scheme_dict, main
2+
3+
4+
def test_get_scheme_dict():
5+
d = get_scheme_dict(distribution_name="foo")
6+
assert set(d.keys()) >= {"purelib", "platlib", "headers", "scripts", "data"}
7+
8+
9+
def test_main(fancy_wheel, tmp_path):
10+
destdir = tmp_path / "dest"
11+
12+
main([str(fancy_wheel), "-d", str(destdir)], "python -m installer")
13+
14+
installed_py_files = destdir.rglob("*.py")
15+
16+
assert {f.stem for f in installed_py_files} == {"__init__", "__main__", "data"}
17+
18+
installed_pyc_files = destdir.rglob("*.pyc")
19+
assert {f.name.split(".")[0] for f in installed_pyc_files} == {
20+
"__init__",
21+
"__main__",
22+
}
23+
24+
25+
def test_main_no_pyc(fancy_wheel, tmp_path):
26+
destdir = tmp_path / "dest"
27+
28+
main(
29+
[str(fancy_wheel), "-d", str(destdir), "--no-compile-bytecode"],
30+
"python -m installer",
31+
)
32+
33+
installed_py_files = destdir.rglob("*.py")
34+
35+
assert {f.stem for f in installed_py_files} == {"__init__", "__main__", "data"}
36+
37+
installed_pyc_files = destdir.rglob("*.pyc")
38+
assert set(installed_pyc_files) == set()

0 commit comments

Comments
 (0)