From 2dcf813f987b48f9a95ae06583e013c0ed6b14ad Mon Sep 17 00:00:00 2001 From: JessicaTegner Date: Sun, 23 Feb 2025 02:06:23 +0100 Subject: [PATCH 1/3] New runner --- pytinytex/__init__.py | 110 +++++++++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 29 deletions(-) diff --git a/pytinytex/__init__.py b/pytinytex/__init__.py index 13bb5a3..8ae0091 100644 --- a/pytinytex/__init__.py +++ b/pytinytex/__init__.py @@ -1,6 +1,6 @@ +import asyncio import sys import os -import subprocess import platform from .tinytex_download import download_tinytex, DEFAULT_TARGET_FOLDER # noqa @@ -74,7 +74,7 @@ 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") @@ -82,33 +82,85 @@ def _run_tlmgr_command(args, path, machine_readable=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") - + 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.""" + try: + while True: + line = await process.stdout.readline() + if not line: # EOF reached + break + line = line.decode('utf-8').rstrip() + output_buffer.append(line) + except Exception as e: + print("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.""" + 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: + print("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. + await asyncio.gather(stdout_task, stdin_task) + else: + # Wait for the stdout task to complete. + await stdout_task + # Return the process return code. + exit_code = await process.wait() + except KeyboardInterrupt: + print("\nKeyboardInterrupt detected, terminating subprocess...") + 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 + + + return process.returncode From c655783ef4be30176fb683d61bc2646d5aca3707 Mon Sep 17 00:00:00 2001 From: JessicaTegner Date: Sun, 23 Feb 2025 02:28:44 +0100 Subject: [PATCH 2/3] added help and shell + logging --- pytinytex/__init__.py | 40 ++++++++++++++++++++++++------------ tests/test_tinytex_runner.py | 8 ++++++++ 2 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 tests/test_tinytex_runner.py diff --git a/pytinytex/__init__.py b/pytinytex/__init__.py index 8ae0091..86316e1 100644 --- a/pytinytex/__init__.py +++ b/pytinytex/__init__.py @@ -2,21 +2,34 @@ import sys import os 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: @@ -84,12 +97,14 @@ def _run_tlmgr_command(args, path, machine_readable=True, interactive=False): creation_flag = 0x08000000 if sys.platform == "win32" else 0 # set creation flag to not open TinyTeX in new console on windows try: + 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() @@ -97,15 +112,16 @@ async def read_stdout(process, output_buffer): break line = line.decode('utf-8').rstrip() output_buffer.append(line) + logger.info(line) except Exception as e: - print("Error in read_stdout:", 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: while True: @@ -116,7 +132,7 @@ async def send_stdin(process): process.stdin.write(user_input.encode('utf-8')) await process.stdin.drain() except Exception as e: - print("Error in send_stdin:", e) + logger.error(f"Error in send_stdin: {e}") finally: if process.stdin: process._transport.close() @@ -142,14 +158,15 @@ async def _run_command(*args, stdin=False, **kwargs): 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: - print("\nKeyboardInterrupt detected, terminating subprocess...") process.terminate() # Gracefully terminate the subprocess. exit_code = await process.wait() finally: @@ -161,6 +178,3 @@ async def _run_command(*args, stdin=False, **kwargs): if exit_code != 0: raise RuntimeError(f"Error running command: {captured_output}") return exit_code, captured_output - - - return process.returncode 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 + From 839f7d336b1035ca260df4f031fc6461038abe96 Mon Sep 17 00:00:00 2001 From: JessicaTegner Date: Sun, 23 Feb 2025 02:52:17 +0100 Subject: [PATCH 3/3] resolve symlink --- pytinytex/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pytinytex/__init__.py b/pytinytex/__init__.py index 86316e1..bcb151b 100644 --- a/pytinytex/__init__.py +++ b/pytinytex/__init__.py @@ -1,4 +1,5 @@ import asyncio +from pathlib import Path import sys import os import platform @@ -92,6 +93,8 @@ def _run_tlmgr_command(args, path, machine_readable=True, interactive=False): 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