"""Error handling when the Python environment is missing."""
import sys
import time
from multiprocessing import Process
from pathlib import Path
from subprocess import PIPE, Popen
COMPLETED = False
MAX_SLEEP_TIME = 5 * 60
ROOT = Path(__file__).parents[2]
def _run_worker_process(process: Process, error_message: str) -> int:
"""Run a worker process with progress dots and timeout handling.
Args:
process: Process instance that executes a worker function.
error_message: Message printed when execution exceeds timeout.
Returns:
``0`` on success, ``1`` on timeout.
"""
process.start()
sleep_time = 0
while process.is_alive():
print(".", end="", flush=True)
time.sleep(0.5)
sleep_time += 1
if sleep_time > MAX_SLEEP_TIME:
process.terminate()
print()
print(error_message, file=sys.stderr)
return 1
print()
return 0
def get_python_version(dry_run: bool = True) -> tuple[str, str]:
"""Determine the command used to invoke Python 3.12 on this platform.
Args:
dry_run: If ``True``, skip process execution and use mocked output.
Returns:
Tuple of executable name and optional version selector argument.
"""
if sys.platform.lower().startswith("win32"):
py = "py"
ver = "-3.12"
cmd = [py, ver, "--version"]
else:
py = "python3.12"
ver = ""
cmd = [py, ver, "--version"]
cmd = list(filter(None, cmd))
msg = "\nRunning"
if dry_run:
msg += " (dry run)"
msg += f":\n -> cmd: {' '.join(cmd)}"
print(msg)
if dry_run:
out = b"Python 3.12.10"
else:
with Popen(cmd, cwd=ROOT, stdout=PIPE) as proc:
out = proc.communicate(timeout=5)[0]
if proc.returncode:
msg = (
"Go To python.org and download the latest version of Python 3.12.\n"
"Then re-run the command."
)
print(msg, file=sys.stderr)
sys.exit(1)
print(f" -> out: Python version: {out.decode(encoding='utf-8').strip()}\n")
return py, ver
def install_confirmation(confirmation: bool) -> None:
"""Request user confirmation before automatic environment installation.
Args:
confirmation: When ``True``, skip interactive confirmation.
"""
if confirmation:
return
print(
"The foxBMS Python environment is missing.\n"
"It can be installed automatically.\n"
"Answer 'Yes' to automatically install the environment or 'No' to "
"abort installation process.",
)
valid_yes = ("y", "ye", "yes")
valid_no = ("n", "no")
while True:
answer = input().lower()
if answer in valid_yes:
yes = True
break
if answer in valid_no:
yes = False
break
print("yes or no answer required.", file=sys.stderr)
if not yes:
msg = (
"This Python environment is required.\n"
"You can also install it manually as shown in the documentation.\n"
"Exiting..."
)
print(msg, file=sys.stderr)
sys.exit(1)
def run_cmd(cmd: list[str], cwd: str = ".", dry_run: bool = True) -> None:
"""Run a command and print execution details.
Args:
cmd: Command and arguments to execute.
cwd: Working directory for command execution.
dry_run: If ``True``, print only and do not execute.
"""
msg = "\nRunning"
if dry_run:
msg += " (dry run)"
msg += f":\n -> cwd: {Path(cwd).absolute()}\n -> cmd: {' '.join(cmd)}"
print(msg)
if dry_run:
return
with Popen(cmd, cwd=cwd, stdout=PIPE, stderr=PIPE) as proc:
proc.communicate()
def create_env(py: str, env_dir: str, ver: str = "", dry_run: bool = True) -> None:
"""Create a virtual environment using the selected Python executable.
Args:
py: Python executable command.
env_dir: Destination directory for the virtual environment.
ver: Optional version selector argument.
dry_run: If ``True``, print only and do not execute.
"""
cmd = [py, ver, "-m", "venv", env_dir]
cmd = list(filter(None, cmd))
run_cmd(cmd, dry_run=dry_run)
def create_env_process(
py: str, env_dir: str, ver: str = "", dry_run: bool = True
) -> int:
"""Run virtual-environment creation in a child process with timeout handling.
Args:
py: Python executable command.
env_dir: Destination directory for the virtual environment.
ver: Optional version selector argument.
dry_run: If ``True``, print only and do not execute.
Returns:
``0`` on success, ``1`` on timeout.
"""
p_create = Process(target=create_env, args=(py, env_dir, ver, dry_run))
return _run_worker_process(
process=p_create,
error_message="Could not create virtual environment",
)
def install_packages(py: str, cwd: str, dry_run: bool = True) -> None:
"""Install required Python packages into the target environment.
Args:
py: Python executable inside the target environment.
cwd: Working directory for installation commands.
dry_run: If ``True``, print only and do not execute.
"""
requirements = ROOT / "requirements.txt"
cmd = [py, "-m", "pip", "install", "-r", str(requirements)]
run_cmd(cmd, cwd, dry_run)
def install_packages_process(py: str, cwd: str, dry_run: bool = True) -> int:
"""Run package installation in a child process with timeout handling.
Args:
py: Python executable inside the target environment.
cwd: Working directory for installation commands.
dry_run: If ``True``, print only and do not execute.
Returns:
``0`` on success, ``1`` on timeout.
"""
p_install = Process(target=install_packages, args=(py, cwd, dry_run))
return _run_worker_process(
process=p_install,
error_message="Could not install packages into the environment",
)
def install_fox_cli_package(py: str, cwd: str, dry_run: bool = True) -> None:
"""Install the local ``fox CLI`` package into the target environment.
Args:
py: Python executable inside the target environment.
cwd: Working directory for installation commands.
dry_run: If ``True``, print only and do not execute.
"""
cmd = [py, "-m", "pip", "install", "."]
run_cmd(cmd, cwd, dry_run)
def install_fox_cli_package_process(
py: str, cwd: str, env_dir: str, dry_run: bool = True
) -> int:
"""Run local package installation in a child process with timeout handling.
Args:
py: Python executable inside the target environment.
cwd: Working directory for installation commands.
env_dir: Environment directory name used in timeout error messages.
dry_run: If ``True``, print only and do not execute.
Returns:
``0`` on success, ``1`` on timeout.
"""
p_install = Process(target=install_fox_cli_package, args=(py, cwd, dry_run))
return _run_worker_process(
process=p_install,
error_message=f"Could not install the 'fox CLI' package into the environment '{env_dir}'",
)
def main(
env_dir: str,
confirm: bool = False,
self_install: bool = False,
dry_run: bool = True,
) -> int:
"""Run the environment installation helper workflow.
Args:
env_dir: Target virtual-environment directory.
confirm: Skip interactive confirmation when ``True``.
self_install: Also install local ``fox CLI`` package when ``True``.
dry_run: If ``True``, print only and do not execute commands.
Returns:
``0`` on success, ``1`` on failure.
"""
print("foxBMS 2 Install Helper")
py, ver = get_python_version(dry_run)
if self_install:
confirm = True
print("--> Successfully determined Python version")
install_confirmation(confirm)
if create_env_process(py=py, env_dir=env_dir, ver=ver, dry_run=dry_run) != 0:
return 1
print("--> Successfully installed the Python environment")
env_py = str((Path(env_dir) / "bin/python").as_posix())
if sys.platform.lower().startswith("win32"):
env_py = str(Path(env_dir) / "Scripts/python.exe")
if install_packages_process(py=env_py, cwd=env_dir, dry_run=dry_run) != 0:
return 1
print("--> Successfully installed the dependencies in the Python environment")
if not self_install:
return 0
if install_fox_cli_package_process(
py=env_py, cwd=str(ROOT), env_dir=env_dir, dry_run=dry_run
):
return 1
print(f"--> Successfully installed 'fox CLI' package to '{env_dir}'")
return 0
def _as_script() -> int:
"""Parse script arguments and run the installer workflow.
Returns:
Exit code from :func:`main`.
"""
env_dir = sys.argv[1]
confirm = False
if "--confirm" in sys.argv:
confirm = True
dry_run = False
if "--dry-run" in sys.argv:
dry_run = True
self_install = False
if "--self-install" in sys.argv:
self_install = True
return main(
env_dir=env_dir, confirm=confirm, self_install=self_install, dry_run=dry_run
)
if __name__ == "__main__":
_as_script()