Skip to content

Commit a871069

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

File tree

2 files changed

+218
-0
lines changed

2 files changed

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

0 commit comments

Comments
 (0)