qutebrowser/scripts/mkvenv.py

442 lines
15 KiB
Python

#!/usr/bin/env python3
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2020 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Create a local virtualenv with a PyQt install."""
import argparse
import pathlib
import sys
import re
import os
import os.path
import shutil
import venv
import subprocess
from typing import List, Optional, Tuple, Dict
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from scripts import utils, link_pyqt
REPO_ROOT = pathlib.Path(__file__).parent.parent
class Error(Exception):
"""Exception for errors in this script."""
def __init__(self, msg, code=1):
super().__init__(msg)
self.code = code
def parse_args(argv: List[str] = None) -> argparse.Namespace:
"""Parse commandline arguments."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--keep',
action='store_true',
help="Reuse an existing virtualenv.")
parser.add_argument('--venv-dir',
default='.venv',
help="Where to place the virtualenv.")
parser.add_argument('--pyqt-version',
choices=pyqt_versions(),
default='auto',
help="PyQt version to install.")
parser.add_argument('--pyqt-type',
choices=['binary', 'source', 'link', 'wheels', 'skip'],
default='binary',
help="How to install PyQt/Qt.")
parser.add_argument('--pyqt-wheels-dir',
default='wheels',
help="Directory to get PyQt wheels from.")
parser.add_argument('--virtualenv',
action='store_true',
help="Use virtualenv instead of venv.")
parser.add_argument('--asciidoc', help="Full path to python and "
"asciidoc.py. If not given, it's searched in PATH.",
nargs=2, required=False,
metavar=('PYTHON', 'ASCIIDOC'))
parser.add_argument('--dev',
action='store_true',
help="Also install dev/test dependencies.")
parser.add_argument('--skip-docs',
action='store_true',
help="Skip doc generation.")
parser.add_argument('--tox-error',
action='store_true',
help=argparse.SUPPRESS)
return parser.parse_args(argv)
def pyqt_versions() -> List[str]:
"""Get a list of all available PyQt versions.
The list is based on the filenames of misc/requirements/ files.
"""
version_set = set()
requirements_dir = REPO_ROOT / 'misc' / 'requirements'
for req in requirements_dir.glob('requirements-pyqt-*.txt'):
version_set.add(req.stem.split('-')[-1])
versions = sorted(version_set,
key=lambda v: [int(c) for c in v.split('.')])
return versions + ['auto']
def run_venv(
venv_dir: pathlib.Path,
executable,
*args: str,
capture_output=False,
capture_error=False,
env=None,
) -> subprocess.CompletedProcess:
"""Run the given command inside the virtualenv."""
subdir = 'Scripts' if os.name == 'nt' else 'bin'
if env is None:
proc_env = None
else:
proc_env = os.environ.copy()
proc_env.update(env)
try:
return subprocess.run(
[str(venv_dir / subdir / executable)] + [str(arg) for arg in args],
check=True,
universal_newlines=capture_output or capture_error,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_error else None,
env=proc_env,
)
except subprocess.CalledProcessError as e:
raise Error("Subprocess failed, exiting") from e
def pip_install(venv_dir: pathlib.Path, *args: str) -> None:
"""Run a pip install command inside the virtualenv."""
arg_str = ' '.join(str(arg) for arg in args)
utils.print_col('venv$ pip install {}'.format(arg_str), 'blue')
run_venv(venv_dir, 'python', '-m', 'pip', 'install', *args)
def delete_old_venv(venv_dir: pathlib.Path) -> None:
"""Remove an existing virtualenv directory."""
if not venv_dir.exists():
return
markers = [
venv_dir / '.tox-config1', # tox
venv_dir / 'pyvenv.cfg', # venv
venv_dir / 'Scripts', # Windows
venv_dir / 'bin', # Linux
]
if not any(m.exists() for m in markers):
raise Error('{} does not look like a virtualenv, cowardly refusing to '
'remove it.'.format(venv_dir))
utils.print_col('$ rm -r {}'.format(venv_dir), 'blue')
shutil.rmtree(str(venv_dir))
def create_venv(venv_dir: pathlib.Path, use_virtualenv: bool = False) -> None:
"""Create a new virtualenv."""
if use_virtualenv:
utils.print_col('$ python3 -m virtualenv {}'.format(venv_dir), 'blue')
try:
subprocess.run([sys.executable, '-m', 'virtualenv', venv_dir],
check=True)
except subprocess.CalledProcessError as e:
raise Error("virtualenv failed, exiting", e.returncode)
else:
utils.print_col('$ python3 -m venv {}'.format(venv_dir), 'blue')
venv.create(str(venv_dir), with_pip=True)
def upgrade_seed_pkgs(venv_dir: pathlib.Path) -> None:
"""Upgrade initial seed packages inside a virtualenv.
This also makes sure that wheel is installed, which causes pip to use its
wheel cache for rebuilds.
"""
utils.print_title("Upgrading initial packages")
pip_install(venv_dir, '-U', 'pip')
pip_install(venv_dir, '-U', 'setuptools', 'wheel')
def requirements_file(name: str) -> pathlib.Path:
"""Get the filename of a requirements file."""
return (REPO_ROOT / 'misc' / 'requirements' /
'requirements-{}.txt'.format(name))
def pyqt_requirements_file(version: str) -> pathlib.Path:
"""Get the filename of the requirements file for the given PyQt version."""
name = 'pyqt' if version == 'auto' else 'pyqt-{}'.format(version)
return requirements_file(name)
def install_pyqt_binary(venv_dir: pathlib.Path, version: str) -> None:
"""Install PyQt from a binary wheel."""
utils.print_title("Installing PyQt from binary")
utils.print_col("No proprietary codec support will be available in "
"qutebrowser.", 'bold')
pip_install(venv_dir, '-r', pyqt_requirements_file(version),
'--only-binary', 'PyQt5,PyQtWebEngine')
def install_pyqt_source(venv_dir: pathlib.Path, version: str) -> None:
"""Install PyQt from the source tarball."""
utils.print_title("Installing PyQt from sources")
pip_install(venv_dir, '-r', pyqt_requirements_file(version),
'--verbose', '--no-binary', 'PyQt5,PyQtWebEngine')
def install_pyqt_link(venv_dir: pathlib.Path) -> None:
"""Install PyQt by linking a system-wide install."""
utils.print_title("Linking system-wide PyQt")
lib_path = link_pyqt.get_venv_lib_path(str(venv_dir))
link_pyqt.link_pyqt(sys.executable, lib_path)
def install_pyqt_wheels(venv_dir: pathlib.Path,
wheels_dir: pathlib.Path) -> None:
"""Install PyQt from the wheels/ directory."""
utils.print_title("Installing PyQt wheels")
wheels = [str(wheel) for wheel in wheels_dir.glob('*.whl')]
pip_install(venv_dir, *wheels)
def apply_xcb_util_workaround(
venv_dir: pathlib.Path,
pyqt_type: str,
pyqt_version: str,
) -> None:
"""If needed (Debian Stable), symlink libxcb-util.so.0 -> .1.
WORKAROUND for https://bugreports.qt.io/browse/QTBUG-88688
"""
utils.print_title("Running xcb-util workaround")
if not sys.platform.startswith('linux'):
print("Workaround not needed: Not on Linux.")
return
if pyqt_type != 'binary':
print("Workaround not needed: Not installing from PyQt binaries.")
return
if pyqt_version not in ['auto', '5.15']:
print("Workaround not needed: Not installing Qt 5.15.")
return
libs = _find_libs()
abi_type = 'libc6,x86-64' # the only one PyQt wheels are available for
if ('libxcb-util.so.1', abi_type) in libs:
print("Workaround not needed: libxcb-util.so.1 found.")
return
try:
libxcb_util_libs = libs['libxcb-util.so.0', abi_type]
except KeyError:
utils.print_error('Workaround failed: libxcb-util.so.0 not found.')
return
if len(libxcb_util_libs) > 1:
utils.print_error(
f'Workaround failed: Multiple matching libxcb-util found: '
f'{libxcb_util_libs}')
return
libxcb_util_path = pathlib.Path(libxcb_util_libs[0])
code = [
'from PyQt5.QtCore import QLibraryInfo',
'print(QLibraryInfo.location(QLibraryInfo.LibrariesPath))',
]
proc = run_venv(venv_dir, 'python', '-c', '; '.join(code), capture_output=True)
venv_lib_path = pathlib.Path(proc.stdout.strip())
link_path = venv_lib_path / libxcb_util_path.with_suffix('.1').name
# This gives us a nicer path to print, and also conveniently makes sure we
# didn't accidentally end up with a path outside the venv.
rel_link_path = venv_dir / link_path.relative_to(venv_dir.resolve())
utils.print_col(f'$ ln -s {libxcb_util_path} {rel_link_path}', 'blue')
link_path.symlink_to(libxcb_util_path)
def _find_libs() -> Dict[Tuple[str, str], List[str]]:
"""Find all system-wide .so libraries."""
all_libs: Dict[str, str] = {}
ldconfig_proc = subprocess.run(
['ldconfig', '-p'],
check=True,
stdout=subprocess.PIPE,
encoding=sys.getfilesystemencoding(),
)
pattern = re.compile(r'(?P<name>\S+) \((?P<abi_type>[^)]+)\) => (?P<path>.*)')
for line in ldconfig_proc.stdout.splitlines():
match = pattern.fullmatch(line.strip())
if match is None:
if 'libs found in cache' not in line:
utils.print_col(f'Failed to match ldconfig output: {line}', 'yellow')
continue
key = match.group('name'), match.group('abi_type')
path = match.group('path')
libs = all_libs.setdefault(key, [])
libs.append(path)
return all_libs
def run_qt_smoke_test(venv_dir: pathlib.Path) -> None:
"""Make sure the Qt installation works."""
utils.print_title("Running Qt smoke test")
code = [
'import sys',
'from PyQt5.QtWidgets import QApplication',
'from PyQt5.QtCore import qVersion, QT_VERSION_STR, PYQT_VERSION_STR',
'print(f"Python: {sys.version}")',
'print(f"qVersion: {qVersion()}")',
'print(f"QT_VERSION_STR: {QT_VERSION_STR}")',
'print(f"PYQT_VERSION_STR: {PYQT_VERSION_STR}")',
'QApplication([])',
'print("Qt seems to work properly!")',
'print()',
]
try:
run_venv(
venv_dir,
'python', '-c', '; '.join(code),
env={'QT_DEBUG_PLUGINS': '1'},
capture_error=True
)
except Error as e:
proc_e = e.__cause__
assert isinstance(proc_e, subprocess.CalledProcessError), proc_e
print(proc_e.stderr)
raise Error(
f"Smoke test failed with status {proc_e.returncode}. "
"You might find additional information in the debug output above.")
def install_requirements(venv_dir: pathlib.Path) -> None:
"""Install qutebrowser's requirement.txt."""
utils.print_title("Installing other qutebrowser dependencies")
requirements = REPO_ROOT / 'requirements.txt'
pip_install(venv_dir, '-r', str(requirements))
def install_dev_requirements(venv_dir: pathlib.Path) -> None:
"""Install development dependencies."""
utils.print_title("Installing dev dependencies")
pip_install(venv_dir,
'-r', str(requirements_file('dev')),
'-r', requirements_file('tests'))
def install_qutebrowser(venv_dir: pathlib.Path) -> None:
"""Install qutebrowser itself as an editable install."""
utils.print_title("Installing qutebrowser")
pip_install(venv_dir, '-e', str(REPO_ROOT))
def regenerate_docs(venv_dir: pathlib.Path,
asciidoc: Optional[Tuple[str, str]]):
"""Regenerate docs using asciidoc."""
utils.print_title("Generating documentation")
if asciidoc is not None:
a2h_args = ['--asciidoc'] + asciidoc
else:
a2h_args = []
script_path = pathlib.Path(__file__).parent / 'asciidoc2html.py'
utils.print_col('venv$ python3 scripts/asciidoc2html.py {}'
.format(' '.join(a2h_args)), 'blue')
run_venv(venv_dir, 'python', str(script_path), *a2h_args)
def run(args) -> None:
"""Install qutebrowser in a virtualenv.."""
venv_dir = pathlib.Path(args.venv_dir)
wheels_dir = pathlib.Path(args.pyqt_wheels_dir)
utils.change_cwd()
if (args.pyqt_version != 'auto' and
args.pyqt_type not in ['binary', 'source']):
raise Error('The --pyqt-version option is only available when installing PyQt '
'from binary or source')
if args.pyqt_wheels_dir != 'wheels' and args.pyqt_type != 'wheels':
raise Error('The --pyqt-wheels-dir option is only available when installing '
'PyQt from wheels')
if not args.keep:
utils.print_title("Creating virtual environment")
delete_old_venv(venv_dir)
create_venv(venv_dir, use_virtualenv=args.virtualenv)
upgrade_seed_pkgs(venv_dir)
if args.pyqt_type == 'binary':
install_pyqt_binary(venv_dir, args.pyqt_version)
elif args.pyqt_type == 'source':
install_pyqt_source(venv_dir, args.pyqt_version)
elif args.pyqt_type == 'link':
install_pyqt_link(venv_dir)
elif args.pyqt_type == 'wheels':
install_pyqt_wheels(venv_dir, wheels_dir)
elif args.pyqt_type == 'skip':
pass
else:
raise AssertionError
apply_xcb_util_workaround(venv_dir, args.pyqt_type, args.pyqt_version)
if args.pyqt_type != 'skip':
run_qt_smoke_test(venv_dir)
install_requirements(venv_dir)
install_qutebrowser(venv_dir)
if args.dev:
install_dev_requirements(venv_dir)
if not args.skip_docs:
regenerate_docs(venv_dir, args.asciidoc)
def main():
args = parse_args()
try:
run(args)
except Error as e:
utils.print_error(str(e))
sys.exit(e.code)
if __name__ == '__main__':
main()