Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI to install to Python running installer #94

Merged
merged 28 commits into from
Jan 25, 2022
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a871069
main: implement CLI
FFY00 Jul 28, 2021
ec29913
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 24, 2021
514d71f
Remove script, use python -m installer for CLI
takluyver Jan 5, 2022
c600a6e
Remove Python version & dependency checks
takluyver Jan 5, 2022
0037f48
Fold destdir support, bytecode compilation into Python API
takluyver Jan 5, 2022
140fd61
Merge remote-tracking branch 'origin/main' into cli
takluyver Jan 6, 2022
d78df15
Default destdir argument to None
takluyver Jan 6, 2022
b04487e
Full stop in docstring
takluyver Jan 6, 2022
d9dd7d2
Rework command-line options for controlling bytecode generation
takluyver Jan 6, 2022
15c992b
Add some tests for CLI interface
takluyver Jan 12, 2022
fdfd28e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 12, 2022
62b632d
Remove unused entrypoint function
takluyver Jan 12, 2022
ac2d729
Remove unused import
takluyver Jan 12, 2022
c8a4390
Mark some code which can't be consistently covered by tests
takluyver Jan 12, 2022
86ff36e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 12, 2022
679106c
Add type hint for kwargs dict
takluyver Jan 12, 2022
73f4fae
Fix typo
takluyver Jan 13, 2022
464644b
Clearer metavar for destdir argument
takluyver Jan 13, 2022
d150edd
Don't generate bytecode by default in Python API
takluyver Jan 14, 2022
973ac95
Don't use distutils to get headers path
takluyver Jan 14, 2022
8263f76
Normalise distribution name to make headers directory
takluyver Jan 14, 2022
e29818e
Remove name normalisation for header install path for now
takluyver Jan 14, 2022
275e73d
Fix headers destination directory in a venv
takluyver Jan 14, 2022
cf6ba41
Remove unused import
takluyver Jan 14, 2022
5264e37
Rename private method
takluyver Jan 15, 2022
00810eb
Pass quiet=1 to compileall
takluyver Jan 19, 2022
546fd2d
Try to fix path embedded in bytecode files
takluyver Jan 19, 2022
05855a9
Merge branch 'main' into cli
takluyver Jan 24, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/installer/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Installer CLI."""

import argparse
import os.path
import re
import sys
import sysconfig
from typing import Dict, Optional, Sequence

import installer
import installer.destinations
import installer.sources
import installer.utils


def main_parser() -> argparse.ArgumentParser:
"""Construct the main parser."""
parser = argparse.ArgumentParser()
parser.add_argument("wheel", type=str, help="wheel file to install")
parser.add_argument(
"--destdir",
"-d",
metavar="path",
type=str,
help="destination directory (prefix to prepend to each file)",
)
parser.add_argument(
"--compile-bytecode",
action="append",
metavar="level",
type=int,
pradyunsg marked this conversation as resolved.
Show resolved Hide resolved
choices=[0, 1, 2],
help="generate bytecode for the specified optimization level(s) (default=0, 1)",
pradyunsg marked this conversation as resolved.
Show resolved Hide resolved
)
parser.add_argument(
"--no-compile-bytecode",
action="store_true",
help="don't generate bytecode for installed modules",
)
return parser


def get_scheme_dict(distribution_name: str) -> Dict[str, str]:
"""Calculate the scheme dictionary for the current Python environment."""
scheme_dict = sysconfig.get_paths()

# calculate 'headers' path, not currently in sysconfig - see
# https://bugs.python.org/issue44445. This is based on what distutils does.
# TODO: figure out original vs normalised names
scheme_dict["headers"] = os.path.join(scheme_dict["include"], distribution_name)
Copy link
Member

@layday layday Jan 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't actually copy what distutils does because (a) include is always on the sys.base_prefix, meaning that installer will attempt to install headers under the base prefix in a virtual env, and (b) when the root is "platlib", the installation will be performed under "purelib".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean about a) - distutils replaces installed_base (= sys.base_prefix) with base (= sys.prefix). We can do the equivalent easily enough, I think.

I'm not sure what you mean about b) though. I don't think that root-is-purelib setting should make any difference to where the headers are installed, and I haven't spotted anything in distutils referring to another destination for them (though I could easily have overlooked it, the logic is fairly spread out).

Copy link
Member

@layday layday Jan 14, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume that if the root is platlib then the headers should be installed in platinclude, i.e. under sys.(base_)exec_prefix. I might be wrong that distutils does this though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

distutils doesn't do this, never mind.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see distutils implementing that, and it doesn't seem right to me. The wheel contents are 5 separate components to be installed - purelib and platlib are separate, but there's just one headers directory, no platheaders. As a convenience, the root of the wheel is considered either platlib or purelib, but I don't think that should affect where any of the other components go.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, great. I'll deal with prefix vs base_prefix at some point later.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest change - 275e73d - should use prefix in place of base_prefix so headers can be installed in a virtualenv.


return scheme_dict


def main(cli_args: Sequence[str], program: Optional[str] = None) -> None:
"""Process arguments and perform the install."""
parser = main_parser()
if program:
parser.prog = program
args = parser.parse_args(cli_args)

bytecode_levels = args.compile_bytecode
if args.no_compile_bytecode:
bytecode_levels = []
elif not bytecode_levels:
bytecode_levels = [0, 1]

with installer.sources.WheelFile.open(args.wheel) as source:
destination = installer.destinations.SchemeDictionaryDestination(
get_scheme_dict(source.distribution),
sys.executable,
installer.utils.get_launcher_kind(),
bytecode_optimization_levels=bytecode_levels,
destdir=args.destdir,
)
installer.install(source, destination, {})


if __name__ == "__main__": # pragma: no cover
main(sys.argv[1:], "python -m installer")
58 changes: 53 additions & 5 deletions src/installer/destinations.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
"""Handles all file writing and post-installation processing."""

import compileall
import io
import os
from typing import TYPE_CHECKING, BinaryIO, Dict, Iterable, Optional, Tuple, Union
import sys
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
BinaryIO,
Collection,
Dict,
Iterable,
Optional,
Tuple,
Union,
)

from installer.records import Hash, RecordEntry
from installer.scripts import Script
Expand Down Expand Up @@ -93,6 +106,8 @@ def __init__(
interpreter: str,
script_kind: "LauncherKind",
hash_algorithm: str = "sha256",
bytecode_optimization_levels: Collection[int] = (),
destdir: Optional[str] = None,
) -> None:
"""Construct a ``SchemeDictionaryDestination`` object.

Expand All @@ -102,11 +117,28 @@ def __init__(
:param hash_algorithm: the hashing algorithm to use, which is a member
of :any:`hashlib.algorithms_available` (ideally from
:any:`hashlib.algorithms_guaranteed`).
:param bytecode_optimization_levels: Compile cached bytecode for
installed .py files with these optimization levels. The bytecode
is specific to the minor version of Python (e.g. 3.10) used to
generate it.
:param destdir: A staging directory in which to write all files. This
is expected to be the filesystem root at runtime, so embedded paths
will be written as though this was the root.
"""
self.scheme_dict = scheme_dict
self.interpreter = interpreter
self.script_kind = script_kind
self.hash_algorithm = hash_algorithm
self.bytecode_optimization_levels = bytecode_optimization_levels
self.destdir = destdir

def _destdir_path(self, scheme: Scheme, path: str) -> str:
file = os.path.join(self.scheme_dict[scheme], path)
if self.destdir is not None:
file_path = Path(file)
rel_path = file_path.relative_to(file_path.anchor)
return os.path.join(self.destdir, rel_path)
return file

def write_to_fs(self, scheme: Scheme, path: str, stream: BinaryIO) -> RecordEntry:
"""Write contents of ``stream`` to the correct location on the filesystem.
Expand All @@ -118,7 +150,7 @@ def write_to_fs(self, scheme: Scheme, path: str, stream: BinaryIO) -> RecordEntr
- Ensures that an existing file is not being overwritten.
- Hashes the written content, to determine the entry in the ``RECORD`` file.
"""
target_path = os.path.join(self.scheme_dict[scheme], path)
target_path = self._destdir_path(scheme, path)
if os.path.exists(target_path):
message = f"File already exists: {target_path}"
raise FileExistsError(message)
Expand Down Expand Up @@ -176,20 +208,32 @@ def write_script(
with io.BytesIO(data) as stream:
entry = self.write_to_fs(Scheme("scripts"), script_name, stream)

path = os.path.join(self.scheme_dict[Scheme("scripts")], script_name)
path = self._destdir_path(Scheme("scripts"), script_name)
mode = os.stat(path).st_mode
mode |= (mode & 0o444) >> 2
os.chmod(path, mode)

return entry

def _compile_bytecode(self, scheme: Scheme, record: RecordEntry) -> None:
"""Compile bytecode for a single .py file."""
if scheme not in ("purelib", "platlib"):
pradyunsg marked this conversation as resolved.
Show resolved Hide resolved
return

target_path = self._destdir_path(scheme, record.path)
for level in self.bytecode_optimization_levels:
kwargs: Dict[str, Any] = {}
if sys.version_info >= (3, 9): # pragma: no cover
kwargs["stripdir"] = str(self.destdir)
takluyver marked this conversation as resolved.
Show resolved Hide resolved
compileall.compile_file(target_path, optimize=level, **kwargs)
takluyver marked this conversation as resolved.
Show resolved Hide resolved

def finalize_installation(
self,
scheme: Scheme,
record_file_path: str,
records: Iterable[Tuple[Scheme, RecordEntry]],
) -> None:
"""Finalize installation, by writing the ``RECORD`` file.
"""Finalize installation, by writing the ``RECORD`` file & compiling bytecode.

:param scheme: scheme to write the ``RECORD`` file in
:param record_file_path: path of the ``RECORD`` file with that scheme
Expand All @@ -205,5 +249,9 @@ def prefix_for_scheme(file_scheme: str) -> Optional[str]:
)
return path + "/"

with construct_record_file(records, prefix_for_scheme) as record_stream:
record_list = list(records)
with construct_record_file(record_list, prefix_for_scheme) as record_stream:
self.write_to_fs(scheme, record_file_path, record_stream)

for scheme, record in record_list:
self._compile_bytecode(scheme, record)
75 changes: 75 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import textwrap
import zipfile

import pytest


@pytest.fixture
def fancy_wheel(tmp_path):
path = tmp_path / "fancy-1.0.0-py2.py3-none-any.whl"
files = {
"fancy/": b"""""",
"fancy/__init__.py": b"""\
def main():
print("I'm fancy.")
""",
"fancy/__main__.py": b"""\
if __name__ == "__main__":
from . import main
main()
""",
"fancy-1.0.0.data/data/fancy/": b"""""",
"fancy-1.0.0.data/data/fancy/data.py": b"""\
# put me in data
""",
"fancy-1.0.0.dist-info/": b"""""",
"fancy-1.0.0.dist-info/top_level.txt": b"""\
fancy
""",
"fancy-1.0.0.dist-info/entry_points.txt": b"""\
[console_scripts]
fancy = fancy:main

[gui_scripts]
fancy-gui = fancy:main
""",
"fancy-1.0.0.dist-info/WHEEL": b"""\
Wheel-Version: 1.0
Generator: magic (1.0.0)
Root-Is-Purelib: true
Tag: py3-none-any
""",
"fancy-1.0.0.dist-info/METADATA": b"""\
Metadata-Version: 2.1
Name: fancy
Version: 1.0.0
Summary: A fancy package
Author: Agendaless Consulting
Author-email: nobody@example.com
License: MIT
Keywords: fancy amazing
Platform: UNKNOWN
Classifier: Intended Audience :: Developers
""",
# The RECORD file is indirectly validated by the WheelFile, since it only
# provides the items that are a part of the wheel.
"fancy-1.0.0.dist-info/RECORD": b"""\
fancy/__init__.py,,
fancy/__main__.py,,
fancy-1.0.0.data/data/fancy/data.py,,
fancy-1.0.0.dist-info/top_level.txt,,
fancy-1.0.0.dist-info/entry_points.txt,,
fancy-1.0.0.dist-info/WHEEL,,
fancy-1.0.0.dist-info/METADATA,,
fancy-1.0.0.dist-info/RECORD,,
""",
}

with zipfile.ZipFile(path, "w") as archive:
for name, indented_content in files.items():
archive.writestr(
name,
textwrap.dedent(indented_content.decode("utf-8")).encode("utf-8"),
)

return path
38 changes: 38 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from installer.__main__ import get_scheme_dict, main


def test_get_scheme_dict():
d = get_scheme_dict(distribution_name="foo")
assert set(d.keys()) >= {"purelib", "platlib", "headers", "scripts", "data"}


def test_main(fancy_wheel, tmp_path):
destdir = tmp_path / "dest"

main([str(fancy_wheel), "-d", str(destdir)], "python -m installer")

installed_py_files = destdir.rglob("*.py")

assert {f.stem for f in installed_py_files} == {"__init__", "__main__", "data"}

installed_pyc_files = destdir.rglob("*.pyc")
assert {f.name.split(".")[0] for f in installed_pyc_files} == {
"__init__",
"__main__",
}


def test_main_no_pyc(fancy_wheel, tmp_path):
destdir = tmp_path / "dest"

main(
[str(fancy_wheel), "-d", str(destdir), "--no-compile-bytecode"],
"python -m installer",
)

installed_py_files = destdir.rglob("*.py")

assert {f.stem for f in installed_py_files} == {"__init__", "__main__", "data"}

installed_pyc_files = destdir.rglob("*.pyc")
assert set(installed_pyc_files) == set()
76 changes: 0 additions & 76 deletions tests/test_sources.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import posixpath
import sys
import textwrap
import zipfile

import pytest
Expand All @@ -9,80 +7,6 @@
from installer.sources import WheelFile, WheelSource


@pytest.fixture
def fancy_wheel(tmp_path):
path = tmp_path / "fancy-1.0.0-py2.py3-none-any.whl"
files = {
"fancy/": b"""""",
"fancy/__init__.py": b"""\
def main():
print("I'm fancy.")
""",
"fancy/__main__.py": b"""\
if __name__ == "__main__":
from . import main
main()
""",
"fancy-1.0.0.data/data/fancy/": b"""""",
"fancy-1.0.0.data/data/fancy/data.py": b"""\
# put me in data
""",
"fancy-1.0.0.dist-info/": b"""""",
"fancy-1.0.0.dist-info/top_level.txt": b"""\
fancy
""",
"fancy-1.0.0.dist-info/entry_points.txt": b"""\
[console_scripts]
fancy = fancy:main

[gui_scripts]
fancy-gui = fancy:main
""",
"fancy-1.0.0.dist-info/WHEEL": b"""\
Wheel-Version: 1.0
Generator: magic (1.0.0)
Root-Is-Purelib: true
Tag: py3-none-any
""",
"fancy-1.0.0.dist-info/METADATA": b"""\
Metadata-Version: 2.1
Name: fancy
Version: 1.0.0
Summary: A fancy package
Author: Agendaless Consulting
Author-email: nobody@example.com
License: MIT
Keywords: fancy amazing
Platform: UNKNOWN
Classifier: Intended Audience :: Developers
""",
# The RECORD file is indirectly validated by the WheelFile, since it only
# provides the items that are a part of the wheel.
"fancy-1.0.0.dist-info/RECORD": b"""\
fancy/__init__.py,,
fancy/__main__.py,,
fancy-1.0.0.data/data/fancy/data.py,,
fancy-1.0.0.dist-info/top_level.txt,,
fancy-1.0.0.dist-info/entry_points.txt,,
fancy-1.0.0.dist-info/WHEEL,,
fancy-1.0.0.dist-info/METADATA,,
fancy-1.0.0.dist-info/RECORD,,
""",
}

if sys.version_info <= (3, 6):
path = str(path)

with zipfile.ZipFile(path, "w") as archive:
for name, indented_content in files.items():
archive.writestr(
name,
textwrap.dedent(indented_content.decode("utf-8")).encode("utf-8"),
)

return path


class TestWheelSource:
def test_takes_two_arguments(self):
WheelSource("distribution", "version")
Expand Down