Skip to content

Commit b30ada1

Browse files
committed
main: implement CLI
Signed-off-by: Filipe Laíns <[email protected]>
1 parent fc5ab85 commit b30ada1

File tree

2 files changed

+204
-0
lines changed

2 files changed

+204
-0
lines changed

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,10 @@ home-page = "https://github.com/pradyunsg/installer"
1010
description-file = "README.md"
1111
classifiers = ["License :: OSI Approved :: MIT License"]
1212
requires-python = ">=3.7"
13+
requires = [
14+
"build >= 0.2.0", # not a hard runtime requirement -- we can softfail
15+
"packaging", # not a hard runtime requirement -- we can softfail
16+
]
17+
18+
[tool.flit.scripts]
19+
python-installer = "installer.__main__:entrypoint"

src/installer/__main__.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""Installer CLI."""
2+
3+
import argparse
4+
import compileall
5+
import distutils.dist
6+
import os
7+
import os.path
8+
import platform
9+
import sys
10+
import sysconfig
11+
import warnings
12+
from email.message import Message
13+
from typing import TYPE_CHECKING, Collection, Dict, Iterable, Optional, Sequence, Tuple
14+
15+
import installer
16+
import installer.destinations
17+
import installer.sources
18+
import installer.utils
19+
from installer.records import RecordEntry
20+
from installer.utils import Scheme
21+
22+
if TYPE_CHECKING:
23+
from installer.scripts import LauncherKind
24+
25+
26+
class _MainDestination(installer.destinations.SchemeDictionaryDestination):
27+
def __init__(
28+
self,
29+
scheme_dict: Dict[str, str],
30+
interpreter: str,
31+
script_kind: "LauncherKind",
32+
hash_algorithm: str = "sha256",
33+
optimization_levels: Collection[int] = (0, 1),
34+
destdir: Optional[str] = None,
35+
) -> None:
36+
if destdir:
37+
destdir = os.path.abspath(destdir)
38+
os.makedirs(destdir, exist_ok=True)
39+
root = os.path.abspath(os.sep)
40+
scheme_dict = {
41+
name: os.path.join(destdir, os.path.relpath(value, root))
42+
for name, value in scheme_dict.items()
43+
}
44+
super().__init__(scheme_dict, interpreter, script_kind, hash_algorithm)
45+
self.optimization_levels = optimization_levels
46+
self.destdir = destdir
47+
48+
def _compile_record(self, scheme: Scheme, record: RecordEntry) -> None:
49+
if scheme not in ("purelib", "platlib"):
50+
return
51+
for level in self.optimization_levels:
52+
target_path = os.path.join(self.scheme_dict[scheme], record.path)
53+
if sys.version_info < (3, 9):
54+
compileall.compile_file(target_path, optimize=level)
55+
else:
56+
compileall.compile_file(
57+
target_path,
58+
optimize=level,
59+
stripdir=self.destdir,
60+
)
61+
62+
def finalize_installation(
63+
self,
64+
scheme: Scheme,
65+
record_file_path: str,
66+
records: Iterable[Tuple[Scheme, RecordEntry]],
67+
) -> None:
68+
record_list = list(records)
69+
super().finalize_installation(scheme, record_file_path, record_list)
70+
for scheme, record in record_list:
71+
self._compile_record(scheme, record)
72+
73+
74+
def main_parser() -> argparse.ArgumentParser:
75+
"""Construct the main parser."""
76+
parser = argparse.ArgumentParser()
77+
parser.add_argument("wheel", type=str, help="wheel file to install")
78+
parser.add_argument(
79+
"--destdir",
80+
"-d",
81+
metavar="/",
82+
type=str,
83+
default="/",
84+
help="destination directory (prefix to prepend to each file)",
85+
)
86+
parser.add_argument(
87+
"--optimize",
88+
"-o",
89+
nargs="*",
90+
metavar="level",
91+
type=int,
92+
default=(0, 1),
93+
help="generate bytecode for the specified optimization level(s) (default=0, 1)",
94+
)
95+
parser.add_argument(
96+
"--skip-dependency-check",
97+
"-s",
98+
action="store_true",
99+
help="don't check if the wheel dependencies are met",
100+
)
101+
return parser
102+
103+
104+
def get_scheme_dict(distribution_name: str) -> Dict[str, str]:
105+
"""Calculate the scheme disctionary for the current Python environment."""
106+
scheme_dict = sysconfig.get_paths()
107+
108+
# calculate 'headers' path, sysconfig does not have an equivalent
109+
# see https://bugs.python.org/issue44445
110+
dist_dict = {
111+
"name": distribution_name,
112+
}
113+
distribution = distutils.dist.Distribution(dist_dict)
114+
install_cmd = distribution.get_command_obj("install")
115+
assert install_cmd
116+
install_cmd.finalize_options()
117+
# install_cmd.install_headers is not type hinted
118+
scheme_dict["headers"] = install_cmd.install_headers # type: ignore
119+
120+
return scheme_dict
121+
122+
123+
def check_python_version(metadata: Message) -> None:
124+
"""Check if the project support the current interpreter."""
125+
try:
126+
import packaging.specifiers
127+
except ImportError:
128+
warnings.warn(
129+
"'packaging' module not available, "
130+
"skiping python version compatibility check"
131+
)
132+
return
133+
134+
requirement = metadata["Requires-Python"]
135+
if not requirement:
136+
return
137+
138+
versions = requirement.split(",")
139+
for version in versions:
140+
specifier = packaging.specifiers.Specifier(version)
141+
if platform.python_version() not in specifier:
142+
raise RuntimeError(
143+
"Incompatible python version, needed: {}".format(version)
144+
)
145+
146+
147+
def check_dependencies(metadata: Message) -> None:
148+
"""Check if the project dependencies are met."""
149+
try:
150+
import build
151+
except ImportError:
152+
warnings.warn("'build' module not available, skiping dependency check")
153+
return
154+
155+
missing = {
156+
unmet
157+
for requirement in metadata.get_all("Requires-Dist") or []
158+
for unmet_list in build.check_dependency(requirement)
159+
for unmet in unmet_list
160+
}
161+
if missing:
162+
missing_list = ", ".join(missing)
163+
raise RuntimeError("Missing requirements: {}".format(missing_list))
164+
165+
166+
def main(cli_args: Sequence[str], program: Optional[str] = None) -> None:
167+
"""Process arguments and perform the install."""
168+
parser = main_parser()
169+
if program:
170+
parser.prog = program
171+
args = parser.parse_args(cli_args)
172+
173+
with installer.sources.WheelFile.open(args.wheel) as source:
174+
# compability checks
175+
metadata_contents = source.read_dist_info("METADATA")
176+
metadata = installer.utils.parse_metadata_file(metadata_contents)
177+
check_python_version(metadata)
178+
if not args.skip_dependency_check:
179+
check_dependencies(metadata)
180+
181+
destination = _MainDestination(
182+
get_scheme_dict(source.distribution),
183+
sys.executable,
184+
installer.utils.get_launcher_kind(),
185+
optimization_levels=args.optimize,
186+
destdir=args.destdir,
187+
)
188+
installer.install(source, destination, {})
189+
190+
191+
def entrypoint() -> None:
192+
"""CLI entrypoint."""
193+
main(sys.argv[1:])
194+
195+
196+
if __name__ == "__main__":
197+
main(sys.argv[1:], "python -m installer")

0 commit comments

Comments
 (0)