lektor-website/assets/installer.py

412 lines
11 KiB
Python
Raw Normal View History

2020-03-20 13:40:10 +01:00
#!/usr/bin/env python
2020-03-20 18:19:55 +01:00
from __future__ import print_function
2020-03-20 13:40:10 +01:00
import math
import os
import shutil
import sys
import tempfile
from subprocess import call
2020-03-20 18:19:55 +01:00
try:
from urllib.request import urlretrieve
except ImportError:
from urllib import urlretrieve
2020-03-20 13:40:10 +01:00
2020-03-20 18:31:53 +01:00
_IS_WIN = sys.platform == "win32"
2020-03-20 13:40:10 +01:00
2020-03-20 18:19:55 +01:00
if not _IS_WIN:
2020-03-20 18:31:53 +01:00
sys.stdin = open("/dev/tty", "r")
2020-03-20 13:40:10 +01:00
2020-03-20 18:19:55 +01:00
if sys.version_info.major == 2:
2020-03-20 13:40:10 +01:00
input = raw_input
2020-03-20 18:31:53 +01:00
_silent = os.environ.get("LEKTOR_SILENT")
2020-03-20 18:19:55 +01:00
_PROMPT = (
_silent is None
2020-03-20 18:31:53 +01:00
or _silent.lower() in ("", "0", "off", "false")
2020-03-20 18:19:55 +01:00
)
2020-03-20 13:40:10 +01:00
2020-03-20 18:31:53 +01:00
2020-03-20 13:40:10 +01:00
def get_confirmation():
2020-03-20 18:19:55 +01:00
if _PROMPT is False:
2020-03-20 13:40:10 +01:00
return
while True:
2020-03-20 18:31:53 +01:00
user_input = input("Continue? [Yn] ").lower().strip()
2020-03-20 13:40:10 +01:00
2020-03-20 18:31:53 +01:00
if user_input in ("", "y"):
2020-03-20 18:19:55 +01:00
print()
2020-03-20 13:40:10 +01:00
return
2020-03-20 18:31:53 +01:00
if user_input == "n":
2020-03-20 18:19:55 +01:00
print()
2020-03-20 18:31:53 +01:00
print("Aborted!")
2020-03-20 13:40:10 +01:00
sys.exit()
2020-03-20 18:31:53 +01:00
2020-03-20 13:40:10 +01:00
def fail(message):
2020-03-20 18:31:53 +01:00
print("Error: %s" % message, file=sys.stderr)
2020-03-20 13:40:10 +01:00
sys.exit(1)
2020-03-20 18:31:53 +01:00
2020-03-20 18:19:55 +01:00
def multiprint(*lines, **kwargs):
2020-03-20 13:40:10 +01:00
for line in lines:
2020-03-20 18:19:55 +01:00
print(line, **kwargs)
2020-03-20 13:40:10 +01:00
class Installer(object):
2020-03-20 18:31:53 +01:00
APP_NAME = "lektor"
VIRTUALENV_URL = "https://bootstrap.pypa.io/virtualenv.pyz"
2020-03-20 13:40:10 +01:00
def __init__(self):
2020-03-20 18:31:53 +01:00
multiprint(
"",
"Welcome to Lektor",
"",
"This script will install Lektor on your computer.",
"",
)
2020-03-20 13:40:10 +01:00
self.compute_location()
self.prompt_installation()
get_confirmation()
if self.check_installation():
self.prompt_wipe()
get_confirmation()
self.wipe_installation()
self.create_virtualenv()
self.install_lektor()
2020-03-20 18:31:53 +01:00
multiprint(
"",
"All done!",
)
2020-03-20 13:40:10 +01:00
def compute_location(self):
# this method must set self.lib_dir
raise NotImplementedError()
def check_installation(self):
raise NotImplementedError()
def wipe_installation(self):
raise NotImplementedError()
def prompt_installation(self):
raise NotImplementedError()
def prompt_wipe(self):
raise NotImplementedError()
def create_virtualenv(self):
self.mkvirtualenv(self.lib_dir)
def install_lektor(self):
2020-03-20 18:31:53 +01:00
call([self.get_pip(), "install", "--upgrade", "Lektor"])
2020-03-20 13:40:10 +01:00
def get_pip(self):
raise NotImplementedError()
@classmethod
def mkvirtualenv(cls, target_dir):
"""
Tries to create a virtualenv by using the built-in `venv` module,
or using the `virtualenv` executable if present, or falling back
to downloading the official zipapp.
"""
created = False
try:
from venv import EnvBuilder
except ImportError:
pass
else:
try:
# TODO: design decision needed:
# on Debian and Ubuntu systems Python is missing `ensurepip`,
# prompting the user to install `python3-venv` instead.
# we could do the same, or go the download route...
import ensurepip
except ImportError:
pass
else:
venv = EnvBuilder(with_pip=True,
2020-03-20 18:19:55 +01:00
symlinks=False if _IS_WIN else True)
2020-03-20 13:40:10 +01:00
venv.create(target_dir)
created = True
if not created:
try:
from shutil import which
except ImportError:
from distutils.spawn import find_executable as which
venv_exec = which("virtualenv")
if venv_exec:
2020-03-20 18:31:53 +01:00
retval = call([venv_exec, "-p", sys.executable, target_dir])
2020-03-20 13:40:10 +01:00
if retval:
sys.exit(1)
created = True
if not created:
zipapp = cls.fetch_virtualenv()
retval = call([sys.executable, zipapp, target_dir])
os.unlink(zipapp)
if retval:
sys.exit(1)
@classmethod
def fetch_virtualenv(cls):
fname = os.path.basename(cls.VIRTUALENV_URL)
root, ext = os.path.splitext(fname)
2020-03-20 18:31:53 +01:00
zipapp = tempfile.mktemp(prefix=root + "-", suffix=ext)
2020-03-20 13:40:10 +01:00
with Progress() as hook:
sys.stdout.write("Downloading virtualenv: ")
urlretrieve(cls.VIRTUALENV_URL, zipapp, reporthook=hook)
return zipapp
@staticmethod
def deletion_error(func, path, excinfo):
2020-03-20 18:31:53 +01:00
multiprint(
"Problem deleting {}".format(path),
"Please try and delete {} manually".format(path),
"Aborted!",
file=sys.stderr,
)
2020-03-20 13:40:10 +01:00
sys.exit(1)
2020-03-20 18:31:53 +01:00
_HOME = os.environ.get("HOME")
2020-03-20 13:40:10 +01:00
class PosixInstaller(Installer):
2020-03-20 18:19:55 +01:00
# prefer system bin dirs
2020-03-20 18:31:53 +01:00
KNOWN_BIN_PATHS = ["/usr/local/bin", "/opt/local/bin"]
if _HOME: # true on *nix, but we need it to prevent blowing up on windows
KNOWN_BIN_PATHS.extend(
[os.path.join(_HOME, ".bin"), os.path.join(_HOME, ".local", "bin"),]
)
2020-03-20 13:40:10 +01:00
def prompt_installation(self):
2020-03-20 18:31:53 +01:00
multiprint(
"Installing at:",
" bin: %s" % self.bin_dir,
" app: %s" % self.lib_dir,
"",
)
2020-03-20 13:40:10 +01:00
def prompt_wipe(self):
2020-03-20 18:31:53 +01:00
multiprint(
"Lektor seems to be installed already.",
"Continuing will delete:",
" %s" % self.lib_dir,
"and remove this symlink:",
" %s" % self.symlink_path,
"",
)
2020-03-20 13:40:10 +01:00
def compute_location(self):
"""
Finds the preferred directory in the user's $PATH,
and derives the lib dir from it.
"""
2020-03-20 18:19:55 +01:00
# look for writable directories in the user's $PATH
# (that are not sbin)
2020-03-20 13:40:10 +01:00
paths = [
2020-03-20 18:31:53 +01:00
item
for item in os.environ["PATH"].split(":")
if not item.endswith("/sbin") and os.access(item, os.W_OK)
2020-03-20 13:40:10 +01:00
]
if not paths:
2020-03-20 18:31:53 +01:00
fail(
"None of the items in $PATH are writable. Run with "
"sudo or add a $PATH item that you have access to."
)
2020-03-20 13:40:10 +01:00
2020-03-20 18:19:55 +01:00
# ... and prioritize them according to KNOWN_BIN_PATHS.
# this makes sure we perform a system install when possible.
2020-03-20 13:40:10 +01:00
def _sorter(path):
try:
return self.KNOWN_BIN_PATHS.index(path)
except ValueError:
2020-03-20 18:31:53 +01:00
return float("inf")
2020-03-20 13:40:10 +01:00
paths.sort(key=_sorter)
lib_dir = None
for path in paths:
2020-03-20 18:31:53 +01:00
if path.startswith(_HOME):
lib_dir = os.path.join(_HOME, ".local", "lib", self.APP_NAME)
2020-03-20 13:40:10 +01:00
break
2020-03-20 18:31:53 +01:00
if path.endswith("/bin"):
2020-03-20 13:40:10 +01:00
parent = os.path.dirname(path)
2020-03-20 18:31:53 +01:00
lib_dir = os.path.join(parent, "lib", self.APP_NAME)
2020-03-20 13:40:10 +01:00
break
if lib_dir is None:
2020-03-20 18:31:53 +01:00
fail("Could not determine installation location for Lektor.")
2020-03-20 13:40:10 +01:00
self.bin_dir = path
self.lib_dir = lib_dir
@property
def symlink_path(self):
return os.path.join(self.bin_dir, self.APP_NAME)
def check_installation(self):
return os.path.exists(self.lib_dir) \
or os.path.lexists(self.symlink_path)
def wipe_installation(self):
if os.path.lexists(self.symlink_path):
os.remove(self.symlink_path)
if os.path.exists(self.lib_dir):
shutil.rmtree(self.lib_dir, onerror=self.deletion_error)
def get_pip(self):
2020-03-20 18:31:53 +01:00
return os.path.join(self.lib_dir, "bin", "pip")
2020-03-20 13:40:10 +01:00
def install_lektor(self):
super(PosixInstaller, self).install_lektor()
2020-03-20 18:31:53 +01:00
bin = os.path.join(self.lib_dir, "bin", "lektor")
2020-03-20 13:40:10 +01:00
os.symlink(bin, self.symlink_path)
class WindowsInstaller(Installer):
2020-03-20 18:31:53 +01:00
APP_NAME = "lektor-cli" # backwards-compatibility with previous installer
LIB_DIR = "lib"
2020-03-20 13:40:10 +01:00
def prompt_installation(self):
2020-03-20 18:31:53 +01:00
multiprint(
"Installing at:",
" %s" % self.install_dir,
"",
)
2020-03-20 13:40:10 +01:00
def prompt_wipe(self):
2020-03-20 18:31:53 +01:00
multiprint(
"Lektor seems to be installed already.",
"Continuing will delete:",
" %s" % self.install_dir,
"",
)
2020-03-20 13:40:10 +01:00
def compute_location(self):
2020-03-20 18:31:53 +01:00
install_dir = os.path.join(os.environ["LocalAppData"], self.APP_NAME)
2020-03-20 13:40:10 +01:00
lib_dir = os.path.join(install_dir, self.LIB_DIR)
self.install_dir = install_dir
self.lib_dir = lib_dir
def check_installation(self):
return os.path.exists(self.install_dir)
def wipe_installation(self):
shutil.rmtree(self.install_dir, onerror=self.deletion_error)
def get_pip(self):
2020-03-20 18:31:53 +01:00
return os.path.join(self.lib_dir, "Scripts", "pip.exe")
2020-03-20 13:40:10 +01:00
def install_lektor(self):
super(WindowsInstaller, self).install_lektor()
2020-03-20 18:31:53 +01:00
exe = os.path.join(self.lib_dir, "Scripts", "lektor.exe")
link = os.path.join(self.install_dir, "lektor.cmd")
2020-03-20 13:40:10 +01:00
2020-03-20 18:31:53 +01:00
with open(link, "w") as link_file:
link_file.write("@echo off\n")
2020-03-20 18:19:55 +01:00
link_file.write('"{}" %*'.format(exe))
2020-03-20 13:40:10 +01:00
self.add_to_path(self.install_dir)
@staticmethod
def add_to_path(location):
try:
from winreg import (
OpenKey, CloseKey, QueryValueEx, SetValueEx,
HKEY_CURRENT_USER, KEY_ALL_ACCESS, REG_EXPAND_SZ,
)
2020-03-20 18:31:53 +01:00
except ImportError: # py2
2020-03-20 13:40:10 +01:00
from _winreg import (
OpenKey, CloseKey, QueryValueEx, SetValueEx,
HKEY_CURRENT_USER, KEY_ALL_ACCESS, REG_EXPAND_SZ,
)
import ctypes
from ctypes.wintypes import HWND, UINT, WPARAM, LPARAM, LPVOID
HWND_BROADCAST = 0xFFFF
WM_SETTINGCHANGE = 0x1A
2020-03-20 18:31:53 +01:00
reg_key = OpenKey(HKEY_CURRENT_USER, "Environment", 0, KEY_ALL_ACCESS)
2020-03-20 13:40:10 +01:00
try:
2020-03-20 18:31:53 +01:00
path_value, _ = QueryValueEx(reg_key, "Path")
2020-03-20 13:40:10 +01:00
except WindowsError:
2020-03-20 18:31:53 +01:00
path_value = ""
2020-03-20 13:40:10 +01:00
2020-03-20 18:31:53 +01:00
paths = path_value.split(";")
2020-03-20 13:40:10 +01:00
if location not in paths:
paths.append(location)
2020-03-20 18:31:53 +01:00
path_value = ";".join(paths)
SetValueEx(reg_key, "Path", 0, REG_EXPAND_SZ, path_value)
2020-03-20 13:40:10 +01:00
SendMessage = ctypes.windll.user32.SendMessageW
SendMessage.argtypes = HWND, UINT, WPARAM, LPVOID
SendMessage.restype = LPARAM
2020-03-20 18:31:53 +01:00
SendMessage(HWND_BROADCAST, WM_SETTINGCHANGE, 0, u"Environment")
2020-03-20 13:40:10 +01:00
class Progress(object):
"A context manager to be used as a urlretrieve reporthook."
def __init__(self):
self.started = False
def progress(self, count, bsize, total):
size = count * bsize
if size > total:
progress = 100
else:
progress = math.floor(100 * size / total)
out = sys.stdout
if self.started:
out.write("\b" * 4)
2020-03-20 18:19:55 +01:00
out.write("{:3d}%".format(progress))
2020-03-20 13:40:10 +01:00
out.flush()
self.started = True
def finish(self):
sys.stdout.write("\n")
def __enter__(self):
return self.progress
def __exit__(self, exc_type, exc_value, traceback):
self.finish()
2020-03-20 18:19:55 +01:00
install = WindowsInstaller if _IS_WIN \
2020-03-20 13:40:10 +01:00
else PosixInstaller
2020-03-20 18:31:53 +01:00
if __name__ == "__main__":
2020-03-20 13:40:10 +01:00
install()