diff --git a/pytinytex/__init__.py b/pytinytex/__init__.py index 13bb5a3..bcb151b 100644 --- a/pytinytex/__init__.py +++ b/pytinytex/__init__.py @@ -1,22 +1,36 @@ +import asyncio +from pathlib import Path import sys import os -import subprocess import platform +import logging from .tinytex_download import download_tinytex, DEFAULT_TARGET_FOLDER # noqa +logger = logging.getLogger("pytinytex") +formatter = logging.Formatter('%(message)s') + +# create a console handler and set the level to debug +ch = logging.StreamHandler() +ch.setFormatter(formatter) +logger.addHandler(ch) + + + # Global cache __tinytex_path = None def update(package="-all"): path = get_tinytex_path() - try: - code, stdout,stderr = _run_tlmgr_command(["update", package], path, False) - return True - except RuntimeError: - raise - return False + return _run_tlmgr_command(["update", package], path, False) + +def help(): + path = get_tinytex_path() + return _run_tlmgr_command(["help"], path, False) +def shell(): + path = get_tinytex_path() + return _run_tlmgr_command(["shell"], path, False, True) def get_tinytex_path(base=None): if __tinytex_path: @@ -74,41 +88,96 @@ def _get_file(dir, prefix): except FileNotFoundError: raise RuntimeError("Unable to find {}.".format(prefix)) -def _run_tlmgr_command(args, path, machine_readable=True): +def _run_tlmgr_command(args, path, machine_readable=True, interactive=False): if machine_readable: if "--machine-readable" not in args: args.insert(0, "--machine-readable") tlmgr_executable = _get_file(path, "tlmgr") + # resolve any symlinks + tlmgr_executable = str(Path(tlmgr_executable).resolve(True)) args.insert(0, tlmgr_executable) new_env = os.environ.copy() creation_flag = 0x08000000 if sys.platform == "win32" else 0 # set creation flag to not open TinyTeX in new console on windows - p = subprocess.Popen( - args, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=new_env, - creationflags=creation_flag) - # something else than 'None' indicates that the process already terminated - if p.returncode is not None: - raise RuntimeError( - 'TLMGR died with exitcode "%s" before receiving input: %s' % (p.returncode, - p.stderr.read()) - ) - - stdout, stderr = p.communicate() - + try: - stdout = stdout.decode("utf-8") - except UnicodeDecodeError: - raise RuntimeError("Unable to decode stdout from TinyTeX") - + logger.debug(f"Running command: {args}") + return asyncio.run(_run_command(*args, stdin=interactive, env=new_env, creationflags=creation_flag)) + except Exception: + raise + +async def read_stdout(process, output_buffer): + """Read lines from process.stdout and print them.""" + logger.debug(f"Reading stdout from process {process.pid}") + try: + while True: + line = await process.stdout.readline() + if not line: # EOF reached + break + line = line.decode('utf-8').rstrip() + output_buffer.append(line) + logger.info(line) + except Exception as e: + logger.error(f"Error in read_stdout: {e}") + finally: + process._transport.close() + return await process.wait() + +async def send_stdin(process): + """Read user input from sys.stdin and send it to process.stdin.""" + logger.debug(f"Sending stdin to process {process.pid}") + loop = asyncio.get_running_loop() try: - stderr = stderr.decode("utf-8") - except UnicodeDecodeError: - raise RuntimeError("Unable to decode stderr from TinyTeX") + while True: + # Offload the blocking sys.stdin.readline() call to the executor. + user_input = await loop.run_in_executor(None, sys.stdin.readline) + if not user_input: # EOF (e.g. Ctrl-D) + break + process.stdin.write(user_input.encode('utf-8')) + await process.stdin.drain() + except Exception as e: + logger.error(f"Error in send_stdin: {e}") + finally: + if process.stdin: + process._transport.close() + + +async def _run_command(*args, stdin=False, **kwargs): + # Create the subprocess with pipes for stdout and stdin. + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + stdin=asyncio.subprocess.PIPE if stdin else asyncio.subprocess.DEVNULL, + **kwargs + ) + + output_buffer = [] + # Create tasks to read stdout and send stdin concurrently. + stdout_task = asyncio.create_task(read_stdout(process, output_buffer)) + stdin_task = None + if stdin: + stdin_task = asyncio.create_task(send_stdin(process)) - if stderr == "" and p.returncode == 0: - return p.returncode, stdout, stderr - else: - raise RuntimeError("TLMGR died with the following error:\n{0}".format(stderr.strip())) - return p.returncode, stdout, stderr + try: + if stdin: + # Wait for both tasks to complete. + logger.debug("Waiting for stdout and stdin tasks to complete") + await asyncio.gather(stdout_task, stdin_task) + else: + # Wait for the stdout task to complete. + logger.debug("Waiting for stdout task to complete") + await stdout_task + # Return the process return code. + exit_code = await process.wait() + except KeyboardInterrupt: + process.terminate() # Gracefully terminate the subprocess. + exit_code = await process.wait() + finally: + # Cancel tasks that are still running. + stdout_task.cancel() + if stdin_task: + stdin_task.cancel() + captured_output = "\n".join(output_buffer) + if exit_code != 0: + raise RuntimeError(f"Error running command: {captured_output}") + return exit_code, captured_output diff --git a/tests/test_tinytex_runner.py b/tests/test_tinytex_runner.py new file mode 100644 index 0000000..3472e49 --- /dev/null +++ b/tests/test_tinytex_runner.py @@ -0,0 +1,8 @@ +import pytinytex +from .utils import download_tinytex, TINYTEX_DISTRIBUTION # noqa + +def test_run_help(download_tinytex): # noqa + exit_code, output = pytinytex.help() + assert exit_code == 0 + assert "TeX Live" in output +