diff --git a/src/pacroller/main.py b/src/pacroller/main.py index f7f0a7d..0810526 100644 --- a/src/pacroller/main.py +++ b/src/pacroller/main.py @@ -5,7 +5,7 @@ import subprocess import logging from re import match import json -from os import environ, getuid +from os import environ, getuid, isatty import traceback import pyalpm import pycman @@ -75,7 +75,7 @@ class alpmCallback: handle.questioncb = self.noop handle.progresscb = self.noop -def upgrade() -> List[str]: +def upgrade(interactive=False) -> List[str]: logger.info('upgrade start') pycman.config.cb_log = lambda *_: None handle = pycman.config.init_with_config(PACMAN_CONFIG) @@ -108,11 +108,11 @@ def upgrade() -> List[str]: examine_upgrade(t.to_add, t.to_remove) finally: t.release() - pacman_output = execute_with_io(['pacman', '-Su', '--noprogressbar', '--color', 'never'], UPGRADE_TIMEOUT) + pacman_output = execute_with_io(['pacman', '-Su', '--noprogressbar', '--color', 'never'], UPGRADE_TIMEOUT, interactive=interactive) logger.info('upgrade end') return pacman_output -def do_system_upgrade(debug=False) -> checkReport: +def do_system_upgrade(debug=False, interactive=False) -> checkReport: for _ in range(NETWORK_RETRY): try: sync() @@ -127,7 +127,7 @@ def do_system_upgrade(debug=False) -> checkReport: try: with open(PACMAN_LOG, 'r') as pacman_log: log_anchor = pacman_log.seek(0, 2) - stdout = upgrade() + stdout = upgrade(interactive=interactive) except subprocess.CalledProcessError as e: if upgrade_err_is_net(e.output): logger.warning('upgrade download failed') @@ -240,12 +240,15 @@ def main() -> None: parser.add_argument('-d', '--debug', action='store_true', help='enable debug mode') parser.add_argument('-v', '--verbose', action='store_true', help='show verbose report') parser.add_argument('-m', '--max', type=int, default=1, help='Number of upgrades to show') + parser.add_argument('-i', '--interactive', choices=['auto', 'on', 'off'], default='auto', help='allow interactive questions') args = parser.parse_args() if args.debug: logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(module)s - %(funcName)s - %(levelname)s - %(message)s') else: logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s') locale_set() + interactive = args.interactive == "on" or not (args.interactive == 'off' or not isatty(0)) + logger.debug(f"interactive questions {'enabled' if interactive else 'disabled'}") if args.action == 'run': if getuid() != 0: @@ -262,7 +265,7 @@ def main() -> None: logger.error(f'Database is locked at {PACMAN_DB_LCK}') exit(2) try: - report = do_system_upgrade(args.debug) + report = do_system_upgrade(debug=args.debug, interactive=interactive) except NonFatal: raise except Exception as e: diff --git a/src/pacroller/utils.py b/src/pacroller/utils.py index 45d0ad4..dc6b275 100644 --- a/src/pacroller/utils.py +++ b/src/pacroller/utils.py @@ -6,6 +6,8 @@ from io import DEFAULT_BUFFER_SIZE from time import mktime from datetime import datetime from signal import SIGINT, SIGTERM, Signals +from select import select +from sys import stdin logger = logging.getLogger() class UnknownQuestionError(subprocess.SubprocessError): @@ -15,7 +17,7 @@ class UnknownQuestionError(subprocess.SubprocessError): def __str__(self): return f"Pacman returned an unknown question {self.question}" -def execute_with_io(command: List[str], timeout: int = 3600) -> List[str]: +def execute_with_io(command: List[str], timeout: int = 3600, interactive: bool = False) -> List[str]: ''' captures stdout and stderr and automatically handles [y/n] questions of pacman @@ -42,23 +44,50 @@ def execute_with_io(command: List[str], timeout: int = 3600) -> List[str]: stderr=subprocess.STDOUT, encoding='utf-8' ) - Thread(target=set_timeout, args=(p, timeout), daemon=True).start() # should be configurable - line = '' - output = '' - while (r := p.stdout.read(1)) != '': - output += r - line += r - if r == '\n': - logger.debug('STDOUT: %s', line[:-1]) - line = '' - elif r == ']': - if line == ':: Proceed with installation? [Y/n]': - p.stdin.write('y\n') - p.stdin.flush() - elif line.lower().endswith('[y/n]'): - terminate(p, signal=SIGINT) - raise UnknownQuestionError(line, output) - + try: + Thread(target=set_timeout, args=(p, timeout), daemon=True).start() + line = '' + output = '' + while (r := p.stdout.read(1)) != '': + output += r + line += r + if r == '\n': + logger.debug('STDOUT: %s', line[:-1]) + line = '' + elif r == ']': + if line == ':: Proceed with installation? [Y/n]': + p.stdin.write('y\n') + p.stdin.flush() + elif line.lower().endswith('[y/n]'): + if interactive: + print(f"Please answer this question in 60 seconds:\n{line}", end=' ', flush=True) + while True: + read_ready, _, _ = select([stdin], list(), list(), 60) + if not read_ready: + terminate(p, signal=SIGINT) + raise UnknownQuestionError(line, output) + choice = read_ready[0].readline().strip() + if choice.lower().startswith('y'): + p.stdin.write('y\n') + p.stdin.flush() + break + elif choice.lower().startswith('n'): + p.stdin.write('n\n') + p.stdin.flush() + break + else: + if choice.lower().startswith('s'): + print(output) + print("Please give an explicit answer [Y]es [N]o [S]how", end=' ', flush=True) + else: + terminate(p, signal=SIGINT) + raise UnknownQuestionError(line, output) + except KeyboardInterrupt: + terminate(p, signal=SIGINT) + raise + except Exception: + terminate(p) + raise if (ret := p.wait()) != 0: raise subprocess.CalledProcessError(ret, command, output) return output.split('\n')