django-for-runners_ynh/conf/install_python.py

355 lines
13 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
DocWrite: install_python.md # Install Python Interpreter
`install_python.py` downloads, builds and installs a Python interpreter, but:
- **only** if the system Python is not the required major version
- **only** once (if the required major version is not already build and installed)
Origin of this script is:
* https://github.com/jedie/manageprojects/blob/main/manageprojects/install_python.py
Licensed under GPL-3.0-or-later (Feel free to copy and use it in your project)
"""
from __future__ import annotations
import argparse
import hashlib
import logging
import os
import re
import shlex
import shutil
import ssl
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path
"""DocWrite: install_python.md # Install Python Interpreter
Minimal needed Python version to run the script is: **v3.9**."""
assert sys.version_info >= (3, 9), f'Python version {sys.version_info} is too old!'
DEFAULT_MAJOR_VERSION = '3.11'
"""DocWrite: install_python.md # Install Python Interpreter
Download Python source code from official Python FTP server:
DocWriteMacro: manageprojects.tests.docwrite_macros.ftp_url"""
PY_FTP_INDEX_URL = 'https://www.python.org/ftp/python/'
"""DocWrite: install_python.md ## Supported Python Versions
The following major Python versions are supported and verified with GPG keys:
DocWriteMacro: manageprojects.tests.docwrite_macros.supported_python_versions
The GPG keys taken from the official Python download page: https://www.python.org/downloads/"""
GPG_KEY_IDS = {
# Thomas Wouters (3.12.x and 3.13.x source files and tags):
'3.13': 'A821E680E5FA6305',
'3.12': 'A821E680E5FA6305',
#
# Pablo Galindo Salgado (3.10.x and 3.11.x source files and tags):
'3.11': '64E628F8D684696D',
'3.10': '64E628F8D684696D',
}
GPG_KEY_SERVER = 'hkps://keys.openpgp.org'
"""DocWrite: install_python.md ## Workflow - 3. Check local installed Python
We assume that the `make altinstall` will install local Python interpreter into:
DocWriteMacro: manageprojects.tests.docwrite_macros.default_install_prefix
See: https://docs.python.org/3/using/configure.html#cmdoption-prefix"""
DEFAULT_INSTALL_PREFIX = '/usr/local'
TEMP_PREFIX = 'setup_python_'
logger = logging.getLogger(__name__)
class TemporaryDirectory:
"""tempfile.TemporaryDirectory in Python 3.9 has no "delete", yet."""
def __init__(self, prefix, delete: bool):
self.prefix = prefix
self.delete = delete
def __enter__(self) -> Path:
self.temp_path = Path(tempfile.mkdtemp(prefix=self.prefix))
return self.temp_path
def __exit__(self, exc_type, exc_val, exc_tb):
if self.delete:
shutil.rmtree(self.temp_path, ignore_errors=True)
if exc_type:
return False
def fetch(url: str) -> bytes:
"""DocWrite: install_python.md # Install Python Interpreter
Download only over verified HTTPS connection."""
context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
with urllib.request.urlopen(url=url, context=context) as response:
return response.read()
def get_html_page(url) -> str:
logger.debug('Getting HTML page from %s', url)
html = fetch(url).decode('utf-8')
assert html, 'Failed to get Python FTP index page'
return html
def extract_versions(*, html, major_version) -> list[str]:
pattern = rf'href="({re.escape(major_version)}\.[0-9]+)'
logger.debug('Extracting versions with pattern: %s', pattern)
versions = re.findall(pattern, html)
versions.sort(reverse=True)
logger.debug('Extracted versions: %s', versions)
return versions
def get_latest_versions(*, html, major_version) -> str:
latest_versions = extract_versions(html=html, major_version=major_version)[0]
logger.info('Latest version of Python %s: %s', major_version, latest_versions)
return latest_versions
def run(args, **kwargs):
logger.debug('Running: %s (%s)', shlex.join(str(arg) for arg in args), kwargs)
return subprocess.run(args, **kwargs)
def run_build_step(args, *, step: str, cwd: Path) -> None:
with tempfile.NamedTemporaryFile(prefix=f'{TEMP_PREFIX}_{step}_', suffix='.txt', delete=False) as temp_file:
logger.info('Running: %s... Output in %s', shlex.join(str(arg) for arg in args), temp_file.name)
try:
subprocess.run(args, stdout=temp_file, stderr=temp_file, check=True, cwd=cwd)
except subprocess.SubprocessError as err:
logger.error('Failed to run %s step: %s', step, err)
run(['tail', temp_file.name])
raise
def get_python_version(python_bin: str | Path) -> str | None:
logger.debug('Check %s version', python_bin)
if output := run([python_bin, '-V'], capture_output=True, text=True).stdout.split():
full_version = output[-1]
logger.info('Version of "%s" is: %r', python_bin, full_version)
return full_version
def download2temp(*, temp_path: Path, base_url: str, filename: str) -> Path:
url = f'{base_url}/{filename}'
dst_path = temp_path / filename
logger.info('Downloading %s into %s...', url, dst_path)
dst_path.write_bytes(fetch(url))
logger.info('Downloaded %s is %d Bytes', filename, dst_path.stat().st_size)
return dst_path
def verify_download(*, major_version: str, tar_file_path: Path, asc_file_path: Path, delete_temp: bool):
"""DocWrite: install_python.md ## Workflow - 5. Verify download
The sha256 hash downloaded tar archive will logged.
If `gpg` is available, the signature will be verified.
"""
hash_obj = hashlib.sha256(tar_file_path.read_bytes())
logger.info('Downloaded sha256: %s', hash_obj.hexdigest())
"""DocWrite: install_python.md # Install Python Interpreter
The Downloaded tar archive will be verified with the GPG signature, if `gpg` is available."""
if gpg_bin := shutil.which('gpg'):
logger.debug('Verifying signature with %s...', gpg_bin)
assert major_version in GPG_KEY_IDS, f'No GPG key ID for Python {major_version}'
gpg_key_id = GPG_KEY_IDS[major_version]
with TemporaryDirectory(prefix='install-python-gpg-', delete=delete_temp) as temp_path:
"""DocWrite: install_python.md ## Workflow - 5. Verify download
We set the `GNUPGHOME` environment variable to a temporary directory."""
env = {'GNUPGHOME': str(temp_path)}
run([gpg_bin, '--keyserver', GPG_KEY_SERVER, '--recv-keys', gpg_key_id], check=True, env=env)
run([gpg_bin, '--verify', asc_file_path, tar_file_path], check=True, env=env)
run(['gpgconf', '--kill', 'all'], check=True, env=env)
else:
logger.warning('No GPG verification possible! (gpg not found)')
def install_python(
major_version: str,
*,
write_check: bool = True,
delete_temp: bool = True,
) -> Path:
logger.info('Requested major Python version: %s', major_version)
"""DocWrite: install_python.md ## Workflow
The setup process is as follows:"""
"""DocWrite: install_python.md ## Workflow - 1. Check system Python
If the system Python is the same major version as the required Python, we skip the installation."""
for try_version in (major_version, '3'):
filename = f'python{try_version}'
logger.debug('Checking %s...', filename)
if python3bin := shutil.which(filename):
if (full_version := get_python_version(python3bin)) and full_version.startswith(major_version):
logger.info('Python version already installed: Return path %r of it.', python3bin)
"""DocWrite: install_python.md ## Workflow - 1. Check system Python
The script just returns the path to the system Python interpreter."""
return Path(python3bin)
"""DocWrite: install_python.md ## Workflow - 2. Get latest Python release
We fetch the latest Python release from the Python FTP server, from:
DocWriteMacro: manageprojects.tests.docwrite_macros.ftp_url"""
# Get latest full version number of Python from Python FTP:
py_required_version = get_latest_versions(
html=get_html_page(PY_FTP_INDEX_URL),
major_version=major_version,
)
local_bin_path = Path(DEFAULT_INSTALL_PREFIX) / 'bin'
"""DocWrite: install_python.md ## Workflow - 3. Check local installed Python
The script checks if the latest release already build and installed."""
local_python_path = local_bin_path / f'python{major_version}'
if local_python_path.exists() and get_python_version(local_python_path) == py_required_version:
logger.info('Local Python is up-to-date')
"""DocWrite: install_python.md ## Workflow - 3. Check local installed Python
If the local Python is up-to-date, the script exist and returns the path this local interpreter."""
return local_python_path
"""DocWrite: install_python.md ## Workflow - 4. Download Python sources
Before we start building Python, check if we have write permissions.
The check can be skipped via CLI argument."""
if write_check and not os.access(local_bin_path, os.W_OK):
raise PermissionError(f'No write permission to {local_bin_path} (Hint: Call with "sudo" ?!)')
"""DocWrite: install_python.md ## Workflow - 4. Download Python sources
The download will be done in a temporary directory. The directory will be deleted after the installation.
This can be skipped via CLI argument. The directory will be prefixed with:
DocWriteMacro: manageprojects.tests.docwrite_macros.temp_prefix"""
with TemporaryDirectory(prefix=TEMP_PREFIX, delete=delete_temp) as temp_path:
base_url = f'{PY_FTP_INDEX_URL}{py_required_version}'
tar_filename = f'Python-{py_required_version}.tar.xz'
asc_filename = f'{tar_filename}.asc'
asc_file_path = download2temp(
temp_path=temp_path,
base_url=base_url,
filename=asc_filename,
)
tar_file_path = download2temp(
temp_path=temp_path,
base_url=base_url,
filename=tar_filename,
)
verify_download(
major_version=major_version,
tar_file_path=tar_file_path,
asc_file_path=asc_file_path,
delete_temp=delete_temp,
)
tar_bin = shutil.which('tar')
logger.debug('Extracting %s with ...', tar_file_path)
run([tar_bin, 'xf', tar_file_path], check=True, cwd=temp_path)
extracted_dir = temp_path / f'Python-{py_required_version}'
logger.info('Building Python %s (may take a while)...', py_required_version)
"""DocWrite: install_python.md ## Workflow - 6. Build and install Python
If the verify passed, the script will start the build process."""
run_build_step(
['./configure', '--enable-optimizations'],
step='configure',
cwd=extracted_dir,
)
run_build_step(
['make', f'-j{os.cpu_count()}'],
step='make',
cwd=extracted_dir,
)
"""DocWrite: install_python.md ## Workflow - 6. Build and install Python
The installation will be done with `make altinstall`."""
run_build_step(
['make', 'altinstall'],
step='install',
cwd=extracted_dir,
)
logger.info('Python %s installed to %s', py_required_version, local_python_path)
local_python_version = get_python_version(local_python_path)
assert local_python_version == py_required_version, f'{local_python_version} is not {py_required_version}'
return local_python_path
def get_parser() -> argparse.ArgumentParser:
"""
DocWrite: install_python.md ## CLI
The CLI interface looks like e.g.:
```shell
$ python3 install_python.py --help
DocWriteMacro: manageprojects.tests.docwrite_macros.help
```
"""
parser = argparse.ArgumentParser(
description='Install Python Interpreter',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
'major_version',
nargs=argparse.OPTIONAL,
default=DEFAULT_MAJOR_VERSION,
choices=sorted(GPG_KEY_IDS.keys()),
help='Specify the Python version',
)
parser.add_argument(
'-v',
'--verbose',
action='count',
default=0,
help='Increase verbosity level (can be used multiple times, e.g.: -vv)',
)
parser.add_argument(
'--skip-temp-deletion',
action='store_true',
help='Skip deletion of temporary files',
)
parser.add_argument(
'--skip-write-check',
action='store_true',
help='Skip the test for write permission to /usr/local/bin',
)
return parser
def main() -> Path:
parser = get_parser()
args = parser.parse_args()
verbose2level = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}
logging.basicConfig(
level=verbose2level.get(args.verbose, logging.DEBUG),
format='%(levelname)9s %(message)s',
stream=sys.stderr,
)
logger.debug('Arguments: %s', args)
return install_python(
major_version=args.major_version,
write_check=not args.skip_write_check,
delete_temp=not args.skip_temp_deletion,
)
if __name__ == '__main__':
python_path = main()
"""DocWrite: install_python.md ## Workflow - 7. print the path
If no errors occurred, the path to the Python interpreter will be printed to `stdout`.
So it's usable in shell scripts, like:
DocWriteMacro: manageprojects.tests.docwrite_macros.example_shell_script
"""
print(python_path)